5 Commits
Author SHA1 Message Date
davide 3fdbfefa62 Documento setup dev e deploy in produzione self-hosted
Passo-passo per avviare lo stack Supabase in locale e per il deploy in
produzione (dominio unico con routing /api/*, migrazioni, build/avvio
Node, job pianificati), coerente con i file infra aggiunti nel commit
precedente.
2026-08-28 14:23:31 +02:00
davide f496eb2f88 Aggiungo stack Supabase self-hosted e config di produzione
Stack Docker completo in infra/supabase/docker/ (vendored da supabase/supabase)
per svincolare dev e produzione da Lovable Cloud. Include override di
produzione che non espone porte pubblicamente e disattiva i servizi non
usati da CrAPP (Realtime, Storage, imgproxy, Edge Functions, pooler),
un Caddyfile con routing /api/* per un solo dominio, e i template .env
per app e stack. Forzo inoltre il preset Nitro a node-server in
vite.config.ts, dato che il default sarebbe Cloudflare Workers.
2026-08-28 14:23:20 +02:00
davide eddee2ec44 Riscrivo il README per riflettere lo stato reale del repository
Il README precedente era la proposta iniziale del progetto (funzionalità
desiderate, comandi npm). Lo sostituisco con stack, funzionalità
effettivamente implementate e comandi bun corretti.
2026-08-28 13:17:44 +02:00
davide 49b6c8a7e9 Correggo documentazione: sync CSI e risultato scout non persistiti server-side
Il CLAUDE.md e la memory affermavano una sincronizzazione CSI e una
persistenza server-side dei risultati partita che non esistono nel
codice: la classifica è un array hardcoded (demo) e il risultato
scoutato vive solo nel localStorage di chi segna.
2026-08-28 13:15:06 +02:00
davide ebd3213a46 Aggiungo CLAUDE.md per tryhardare con claude 2026-08-28 12:09:31 +02:00
472 changed files with 23174 additions and 41178 deletions

No files matched your search

-7
View File
@@ -1,7 +0,0 @@
{
"enabledPlugins": {
"vercel@claude-plugins-official": true,
"supabase@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true
}
}
-293
View File
@@ -1,293 +0,0 @@
---
name: apple-design
description: Apple's approach to interface design and fluid, physical motion, translated for the web. Use when building or reviewing gesture-driven UI, spring animations, drag/swipe/sheet interactions, momentum and interruptible transitions, translucent materials and depth, typography (optical sizing, tracking, leading), reduced-motion, or the design foundations (feedback, spatial consistency, restraint) behind Apple-style interfaces.
---
# Apple Design
How Apple builds interfaces that stop feeling like a computer and start feeling like an extension of you. This knowledge comes from Apple's WWDC design talks — chiefly _Designing Fluid Interfaces_ (WWDC 2018) — distilled and translated into the web platform (CSS, Pointer Events, `requestAnimationFrame`, spring libraries like Motion/Framer Motion).
The through-line: **an interface feels alive when motion starts from the current on-screen value, inherits the user's velocity, projects momentum forward, and can be grabbed and reversed at any instant.** Springs are the tool that makes all of this natural, because they are inherently interruptible and velocity-aware.
## The Core Idea
> "When we align the interface to the way we think and move, something magical happens — it stops feeling like a computer and starts feeling like a seamless extension of us."
An interface is fluid when it behaves like the physical world: things respond instantly, move continuously, carry momentum, resist at boundaries, and can be redirected mid-motion. Everything below is a way to get closer to that.
Apple frames design as serving four human needs: **safety/predictability, understanding, achievement, and joy.** Every rule here serves one of them.
## 1. Response — kill latency
The moment lag appears, the feeling of directness "falls off a cliff." Response is the foundation everything else is built on.
- **Respond on pointer-down, not on release.** Highlight a button the instant it's pressed. Waiting for `click`/touch-up to show feedback feels dead.
- **Be vigilant about every latency.** Audit debounces, artificial timers, transition waits, and the ~300ms tap delay. Anything on the input path that isn't essential is a regression.
- **Feedback must be continuous _during_ the interaction, not just at the end.** For a drag, slider, or drawer, update the UI 1:1 with the pointer the whole way through — never animate only when the gesture completes.
```css
/* Feedback lives on the press, and it's instant */
.button:active {
transform: scale(0.97);
transition: transform 100ms ease-out;
}
```
## 2. Direct manipulation — 1:1 tracking
> "Touch and content should move together."
When the user drags something, it must stay glued to the finger — and respect the offset from _where they grabbed it_. Snapping to the element's center on grab breaks the illusion immediately.
- Use Pointer Events with `setPointerCapture` so tracking continues even when the pointer leaves the element's bounds.
- Track a short **velocity/position history** (last few `pointermove` events), not just the current point — you'll need velocity at release.
```js
el.addEventListener("pointerdown", (e) => {
el.setPointerCapture(e.pointerId);
const grabOffset = e.clientY - el.getBoundingClientRect().top; // respect where they grabbed
// ...track position + timestamp history for velocity
});
```
## 3. Interruptibility — the single most important principle
> "The thought and the gesture happen in parallel."
Every animation must be interruptible and redirectable at any moment. A user must be able to grab a moving element mid-flight and reverse it without waiting for the animation to finish. A closing modal the user grabs again should follow the finger — not finish closing first, then reopen.
- **Never lock out input during a transition.**
- **Always animate from the _presentation_ (current) value, never the target value.** On interrupt, read the element's live on-screen transform and start the new animation from there. Starting from the logical/target value causes a visible jump.
- **Avoid CSS transitions and `@keyframes` for anything gesture-driven** — they can't be smoothly grabbed and reversed mid-flight. Springs animate from the current value by default, which is exactly what interruption needs.
- **When a gesture reverses, blend velocity — don't hard-cut it.** Replacing one animation with another at a reversal creates a velocity discontinuity, a "brick wall." Spring libraries that carry velocity through a re-target avoid it. (This is what iOS's _additive animations_ do natively; on the web, choose a spring library that re-targets from the current velocity.)
- **Decompose 2D motion into independent X and Y springs.** A single spring on a 2D distance desyncs when X and Y have different velocities.
## 4. Behavior over animation — use springs
> "Think of animation as a conversation between you and the object, not something prescribed by the interface."
A pre-scripted, fixed-duration animation can't respond to new input. A spring can — new input just changes the target, and the motion stays continuous. Reach for springs for anything a user can touch.
Apple deliberately replaced the physics triplet (mass/stiffness/damping) with two designer-friendly parameters. Think in these:
- **Damping ratio** — controls overshoot. `1.0` = critically damped, no bounce, smooth settle. `< 1.0` = overshoots and oscillates. Lower = bouncier.
- **Response** — how quickly the value reaches the target, in seconds. Lower = snappier. **This is not "duration"** — a spring has no fixed duration; its settle time emerges from the parameters.
**Defaults:**
- Start most UI at **damping `1.0`** (critically damped) — graceful and non-distracting.
- Add bounce (**damping ~`0.8`**) **only when the gesture itself carried momentum** (a flick, a throw, a drag release). Overshoot on a menu that just faded in feels wrong; overshoot on a card you flicked feels right.
**Concrete values Apple ships:**
| Interaction | Damping | Response |
| ---------------------------- | ------- | -------- |
| Move / reposition (e.g. PiP) | `1.0` | `0.4` |
| Rotation | `0.8` | `0.4` |
| Drawer / sheet | `0.8` | `0.3` |
**Web mapping (Motion / Framer Motion):** the `bounce` + `duration` spring API maps closely to Apple's damping + response. A safe house style is `damping: 1.0` springs everywhere by default; reserve bounce for momentum-driven, physical interactions.
```js
import { animate } from "motion";
// Critically damped default (no overshoot)
animate(el, { y: 0 }, { type: "spring", bounce: 0, duration: 0.4 });
// Momentum interaction — a little bounce, only because a flick preceded it
animate(el, { y: target }, { type: "spring", bounce: 0.2, duration: 0.4 });
```
## 5. Velocity handoff — the seam between drag and animation
When a gesture ends, the animation must **continue at the finger's exact velocity**, so there's no visible seam between dragging and animating. This is the detail that most separates "fluid" from "fine."
Pass the pointer's release velocity as the spring's initial velocity. Some spring APIs want **relative** velocity — normalize it by the remaining distance to the target:
```
relativeVelocity = gestureVelocity / (targetValue − currentValue)
```
Example: element at `y=50`, target `y=150` (100px to go), finger moving 50px/s → initial spring velocity = `50 / 100 = 0.5`. Framer Motion / Motion take absolute px/s velocity directly (`velocity` option), so you usually hand it the raw value.
## 6. Momentum projection — animate to where the gesture is _going_
> "Take a small input and make a big output."
Don't snap to the nearest boundary from the _release point_. Use velocity to **project the resting position** — exactly like scroll deceleration — then snap to the target nearest that projected point. This is what makes a flick feel like it throws the element.
Apple's exact projection function (from the _Designing Fluid Interfaces_ sample code):
```js
// decelerationRate ≈ 0.998 for normal scroll feel; 0.99 for snappier
function project(initialVelocity /* px/s */, decelerationRate = 0.998) {
return ((initialVelocity / 1000) * decelerationRate) / (1 - decelerationRate);
}
const projectedEndpoint = currentPosition + project(releaseVelocity);
const target = nearestSnapPoint(projectedEndpoint); // choose target from the projection
animateSpringTo(target, { velocity: releaseVelocity }); // then hand off velocity (§5)
```
Note: the physics-textbook `v²/(2·decel)` is _not_ what Apple ships — use the exponential-decay form above. This is the standard behavior in good bottom-sheets and carousels (Vaul, Embla).
## 7. Spatial consistency — symmetric paths, anchored origins
> "If something disappears one way, we expect it to emerge from where it came."
- **Enter and exit along the same path.** A panel that slides in from the right must dismiss to the right. In-from-right / out-the-bottom feels disconnected and confusing.
- **Anchor interactions to their source.** A menu, popover, or sheet should originate from the element that triggered it — set `transform-origin` to the trigger, so the spatial relationship between button and content is obvious. (This is the same origin-awareness point as popovers scaling from their trigger, not their center.)
- **Mirror the easing on reversible transitions** so the outbound path matches the return path (use inverse cubic-bézier control points for the two directions).
## 8. Hint in the direction of the gesture
Humans predict a final state from a trajectory. Intermediate motion should telegraph where things are going — Control Center modules "grow up and out toward your finger." Make the in-between frames point at the outcome, not just interpolate blindly to it.
## 9. Rubber-banding — soft boundaries
At an edge, resist progressively instead of stopping hard. A hard stop reads as "frozen"; continuous resistance reads as "responsive, but there's nothing more here." Apply damping that increases the further past the boundary the user drags.
```js
// The further past the bound, the less the element follows — real things slow before they stop
function rubberband(overshoot, dimension, constant = 0.55) {
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
}
```
## 10. Gesture design details (the "feel" checklist)
- **Tap:** highlight on touch-_down_ (instant), commit on touch-_up_. Add ~10px of hysteresis/hit padding around the target, and allow cancel-by-dragging-away and back.
- **Drag/swipe:** require a small movement threshold (hysteresis, ~10px) before committing to a direction, then track 1:1.
- **Detect all plausible gestures in parallel from the first move**, then confidently cancel the losers once intent is clear. Avoid recognizers that only report a _final_ state (`swipeleft`-type events) — they throw away the continuous tracking you need for feedback.
- **Minimize disambiguation delays.** Double-tap detection unavoidably delays single taps; only pay that cost where double-tap truly exists.
## 11. Frame-level smoothness
Smoothness is about _what's in the frames_, not just the frame rate.
- Keep the per-frame positional change below the perception threshold to avoid strobing.
- For very fast motion, a subtle **motion blur / stretch** encodes speed and reads better than a hard sharp streak.
- `requestAnimationFrame` is the web's display-synced clock (Apple uses `CADisplayLink`). Animate only compositor-friendly properties — `transform` and `opacity` — and hint with `will-change` where motion is imminent.
## 12. Materials & depth — translucency conveys hierarchy
Apple uses translucent materials as a floating functional layer that brings structure without stealing focus. On the web, approximate with `backdrop-filter`.
- **Build nav/toolbars/sheets as translucent layers** (`backdrop-filter: blur()` + a semi-transparent background) with content scrolling underneath — not opaque bars that consume a fixed strip.
- **Material weight encodes hierarchy:** darker/heavier materials separate structural regions (sidebars); lighter materials draw attention to interactive elements (buttons). **Never stack a light translucent surface on another** — legibility collapses.
- **Bigger surfaces should read as thicker:** stronger blur + a deeper shadow than small chips. Consider context-aware shadow — heavier over busy/text content for separation, lighter over plain backgrounds.
- **Dim to focus, separate to keep flow.** A modal task pairs the surface with a dimming scrim and pushes the background back/down. A parallel, non-blocking panel uses translucency and offset _without_ a scrim so the flow isn't broken. For stacked sheets, progressively dim and push back each parent layer.
- **Vibrancy keeps text legible over changing backgrounds.** Over blurred/translucent surfaces, don't use flat gray text — use higher-contrast, slightly heavier weight, and a small letter-spacing bump. Put color on a solid layer, not the translucent foreground.
- **Scroll edge effects, not hard dividers.** Instead of a 1px border under a sticky header, fade a small blur/gradient mask where content meets floating chrome — only where floating UI actually overlaps content.
- **Materialize, don't just fade.** For glass/blur surfaces, animate blur radius and scale together on enter/exit, so the surface reads as a real material arriving rather than a plain opacity fade.
```css
.toolbar {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px) saturate(180%);
border-top: 1px solid rgba(255, 255, 255, 0.4); /* bright top edge = light catching the material */
}
```
## 13. Multimodal feedback — motion + sound + haptics
Three rules for combining senses (from _Designing Audio-Haptic Experiences_):
1. **Causality** — it must be obvious what caused the feedback. Trigger it on the actual causal event (the toggle flipping, the item snapping home), and match its character to the action's physicality.
2. **Harmony** — the visual, the sound, and the haptic must fire on the **same frame**. Latency between them destroys the illusion. Don't let a CSS transition lag the audio/haptic (Vibration API).
3. **Utility** — add feedback only where it earns its place. Reserve haptics/sound for meaningful moments (success, error, commit, snap). Over-feedback trains users to ignore all of it.
## 14. Reduced motion & accessibility
Reduced motion doesn't mean _no_ feedback — it means a gentler, non-vestibular equivalent. Respond to three independent signals and bake them into your components:
- **`prefers-reduced-motion: reduce`** — replace slides/springs/parallax with short opacity **cross-fades or static transitions**. Drop elastic/overshoot. Keep opacity/color changes that aid comprehension.
- **`prefers-reduced-transparency: reduce`** — make translucent surfaces frostier/solid: raise background opacity, drop the blur.
- **`prefers-contrast: more`** — near-solid backgrounds with a defined, contrasting border.
Also: avoid full-viewport moving backgrounds, slow looping oscillations (near 0.2 Hz / one cycle per 5s), and abrupt brightness jumps (ease dark↔light theme changes). Make large moving objects semi-transparent while they travel, and fade big surfaces out during a large reposition and back in once settled.
```css
@media (prefers-reduced-motion: reduce) {
.sheet {
transition: opacity 200ms ease;
transform: none !important;
}
}
@media (prefers-reduced-transparency: reduce) {
.toolbar {
background: white;
backdrop-filter: none;
}
}
```
## 15. Typography — optical sizing, tracking, leading
Apple designs type to change shape with size; the same discipline applies on the web. (From _The Details of UI Typography_, WWDC 2020.)
- **Tracking (letter-spacing) is size-specific — never one value for all sizes.** Large display text wants _negative_ tracking (letters read too far apart as they grow); small text wants slightly _positive_ tracking for legibility. A fixed `letter-spacing` is wrong somewhere. Tighten headings, leave body near `0`.
- **Leading (line-height) tracks size inversely.** Tight on large headings, looser on body copy. Increase it for scripts with tall ascenders/descenders; tighten it for dense, information-heavy UI.
- **Build hierarchy from weight + size + leading as a set,** not size alone. Emphasize with weight — it adds presence without taking more space.
- **Respect the user's text-size setting** (Dynamic Type). Scale layout _with_ the text — spacing in `rem`/`em`, not fixed px — so a larger font doesn't break the layout.
- **Default to the platform's system font** before a custom face; it already ships optical sizing, tracking tables, and legibility tuning. Override only with a reason.
```css
:root {
font:
100%/1.5 system-ui,
sans-serif;
} /* body: system font, comfortable leading */
.display {
font-size: clamp(2rem, 5vw, 4rem);
line-height: 1.05; /* tight leading for large text */
letter-spacing: -0.02em; /* negative tracking as it grows */
font-optical-sizing: auto;
}
```
## 16. Design foundations — the eight principles
The motion and craft above serve Apple's eight design principles (_Principles of Great Design_, WWDC 2026). Use these as the names you reason with:
1. **Purpose.** Make with intention; decide what _not_ to build. Every feature asks for the user's time, attention, and trust — spend that budget only where it pays off.
2. **Agency.** Keep people in control: offer choices, don't force a single path. Back it with forgiveness — easy undo for slips, a confirmation dialog only for genuinely destructive, irreversible actions (use sparingly; overusing it trains people to click through).
3. **Responsibility.** Act in the user's interest. Privacy: ask at the right moment, only for what's needed, transparently. Safety: anticipate misuse and harm — especially with AI (an allergy-aware recipe app must not suggest a harmful ingredient). Add previews, confirmations, disclaimers; cut a feature whose risk outweighs its value.
4. **Familiarity.** Build on what people already know. Use metaphors that are neither too literal nor too abstract (a trash can means delete), and honor their physics. Be consistent: things that look the same must behave the same and live in the same place (close is always top-left on macOS) so people can predict what happens next. Only break a familiar pattern if you can prove it's better — then test it, don't assume.
5. **Flexibility.** Design for different contexts, devices, and the full range of abilities. Adapt to the platform (iPhone = quick touch; desktop = deep workflows with precise pointer control) and to the situation. Design inclusively (age, language, expertise, accessibility). When no single layout fits everyone, let people personalize — rearrange controls, hide what they don't use.
6. **Simplicity — not minimalism.** Strip the unnecessary so the core purpose shines; burying everything in one place looks minimal but isn't simple. Be concise (plain language, no jargon, fewer steps) and clear (use hierarchy — order, spacing, contrast — so the most important thing is the most obvious). Every element earns its place; sometimes _adding_ context simplifies (a video scrubber that shows time remaining). Show the common path first, advanced options one level deeper.
7. **Craft.** Uncompromising attention to detail builds trust. Beautiful typography, colors that adapt to light/dark, clear iconography, and responsive animations that give immediate, natural feedback. Nothing is random — every spacing, timing, and alignment value is a deliberate choice you can defend. Jittery scroll, misaligned icons, and layouts that break on rotation read as carelessness. Craft needs iteration and longevity — keep evolving the design as features and hardware change.
8. **Delight.** The result of getting the other seven right, not confetti tacked on top. Decide the emotion you want people to feel (calm, confident, excited) and reinforce it in every decision.
Tactical rules that serve these:
- **Feedback comes in four kinds:** status, completion, warning, error. Confirm meaningful actions, expose ongoing status, warn before problems, validate inline (not on submit).
- **Wayfinding.** Every screen should answer: Where am I? Where can I go? What's there? How do I get out? Never trap the user.
- **Grouping & mapping.** Proximity implies relationship; place a control near what it affects and arrange controls to mirror what they change. If you need a label to explain a control, the mapping is weak.
- **Direct, specific labels beat safe generic ones.** Name nav items for their contents ("Progress", "Library"), not vague umbrellas ("Home"). Specificity creates predictability.
## 17. Process
- **Prototype interactively — an interactive demo is worth "a million static designs."** You discover the interface by building and playing with it; a working prototype also sets a concrete bar that prevents a mediocre final implementation.
- **Design interaction and visuals together.** "You shouldn't be able to tell where one ends and the other begins." Motion is not a layer added after the pixels.
- **Test with real people in real context**, and review motion with fresh eyes — play it in slow motion / frame-by-frame to catch what's invisible at full speed.
## Quick Reference
| Need | Technique | Concrete value |
| --------------------------- | ------------------------------------ | ---------------------------------------------------- |
| Default UI spring | Critically damped, no overshoot | `damping 1.0`, `response 0.3–0.4` |
| Momentum / flick spring | Under-damped, slight bounce | `damping ~0.8`, `response 0.3–0.4` |
| Gesture → spring velocity | Hand off release velocity | `gestureVelocity / (target − current)` if normalized |
| Flick landing point | Project momentum | `current + (v/1000)·d/(1−d)`, `d ≈ 0.998` |
| Interrupt cleanly | Start from presentation (live) value | read the on-screen transform |
| Avoid reversal "brick wall" | Carry velocity through re-target | spring that blends velocity |
| Reversible transition | Mirror the easing curve | inverse cubic-bézier |
| Decide reverse vs. commit | Use velocity **sign**, not position | at release |
| 1:1 drag | Pointer Events + capture | respect the grab offset |
| Feedback | On pointer-down, continuous | never only at the end |
| Boundary | Rubber-band, don't hard-stop | progressive resistance |
| Translucent chrome | `backdrop-filter` layer | content scrolls under |
| Type tracking | Size-specific, never fixed | tighten large text (`-0.02em`), body near `0` |
| Reduced motion | Cross-fade, not slide/spring | `@media (prefers-reduced-motion)` |
-15
View File
@@ -1,15 +0,0 @@
---
description: Regole di progetto CrAPP
alwaysApply: true
---
Prima di qualsiasi modifica leggi @AGENTS.md e seguine le regole: sono vincolanti e valgono
per intero.
- Non implementare funzionalità non documentate in `docs/`.
- Codice, commenti e documentazione in italiano.
Non aggiungere regole in questo file: una regola nuova va in `AGENTS.md`, che leggono anche
Claude Code e Codex. Vale per qualsiasi aggiunta o modifica — regola, funzionalità, decisione,
schema database: prima di considerare finito il lavoro esegui la checklist «Fine lavoro» di
`AGENTS.md`.
+15
View File
@@ -0,0 +1,15 @@
# TEMPLATE DI PRODUZIONE per l'app CrAPP — copia in .env sul server e compila.
# I valori Supabase vengono dal .env dello stack self-hosted in infra/supabase/docker/
# (ANON_KEY -> VITE_SUPABASE_PUBLISHABLE_KEY, SERVICE_ROLE_KEY -> SUPABASE_SERVICE_ROLE_KEY).
VITE_SUPABASE_URL=https://tuodominio.it/api
VITE_SUPABASE_PUBLISHABLE_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# Notifiche push (genera con: npx web-push generate-vapid-keys)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:tuamail@esempio.it
# Porta su cui ascolta il server Node generato da Nitro (preset node-server)
PORT=3000
-70
View File
@@ -1,70 +0,0 @@
name: 🐛 Segnala un bug
description: Segnala un comportamento non corretto di CrAPP
title: "[Bug]: "
labels:
- bug
body:
- type: input
id: titolo-bug
attributes:
label: Titolo del bug
description: Riassumi il problema in una frase
placeholder: "Es. Il numero di maglia duplicato non blocca il salvataggio"
validations:
required: true
- type: textarea
id: comportamento-attuale
attributes:
label: Cosa succede
description: Descrivi il comportamento che hai osservato
validations:
required: true
- type: textarea
id: comportamento-atteso
attributes:
label: Cosa ti aspettavi succedesse
validations:
required: true
- type: textarea
id: passaggi
attributes:
label: Passaggi per riprodurre
placeholder: |
1. Vai su ...
2. Clicca su ...
3. Compare l'errore ...
validations:
required: true
- type: dropdown
id: ambiente
attributes:
label: Dispositivo/browser
description: Da dove hai riscontrato il problema
options:
- Smartphone (Chrome/Safari)
- Computer (Chrome)
- Computer (Safari)
- Computer (Firefox)
- Altro
validations:
required: false
- type: textarea
id: screenshot
attributes:
label: Screenshot
description: Trascina qui una o più immagini che mostrano il problema (opzionale)
validations:
required: false
- type: checkboxes
id: duplicati
attributes:
label: Verifica
options:
- label: Ho controllato che non esista già una issue aperta su questo stesso problema
required: true
-1
View File
@@ -1 +0,0 @@
blank_issues_enabled: false
@@ -1,55 +0,0 @@
name: ✨ Richiesta di funzionalità
description: Suggerisci una nuova funzionalità per CrAPP
title: "[Feature]: "
labels:
- enhancement
body:
- type: input
id: titolo-feature
attributes:
label: Titolo della funzionalità
description: Riassumi la proposta in una frase
placeholder: "Es. Notifica push quando viene aggiunto un nuovo evento"
validations:
required: true
- type: textarea
id: problema
attributes:
label: Problema che risolve
description: Cosa non riesci a fare oggi, o cosa ti costa troppa fatica
placeholder: "Es. Non mi accorgo quando viene aggiunta una partita finché non apro l'app"
validations:
required: true
- type: textarea
id: soluzione
attributes:
label: Soluzione proposta
description: Come immagini che dovrebbe funzionare
validations:
required: true
- type: textarea
id: alternative
attributes:
label: Alternative considerate
description: Altri modi in cui potresti risolvere lo stesso problema (opzionale)
validations:
required: false
- type: textarea
id: screenshot
attributes:
label: Screenshot o mockup
description: Trascina qui immagini di riferimento (opzionale)
validations:
required: false
- type: checkboxes
id: duplicati
attributes:
label: Verifica
options:
- label: Ho controllato che non esista già una richiesta simile
required: true
+3 -11
View File
@@ -39,15 +39,7 @@ dist-ssr
# Optional
.vercel
# Supabase CLI local state
supabase/.temp/
# Backup locali del database (contengono dati reali degli utenti):
# ignora tutto il contenuto generato, ma tieni in git lo script e il README.
backups/*
!backups/backup.sh
!backups/download-storage.mjs
!backups/storage-core.ts
!backups/README.md
# Automazioni operative locali (backup cifrati, playbook con dati sensibili)
ops/
# Supabase self-host: dati runtime, mai versionati
infra/supabase/docker/volumes/db/data/
infra/supabase/docker/volumes/storage/
@@ -0,0 +1,31 @@
# Obiettivi di squadra: sezione in Squadra + widget in home
## Stato attuale
Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/routes/index.tsx`, riga 136): **"90% di presenze ad agosto"** con una barra finta all'82%. Non esiste uno schema dati né una sezione dedicata, e non è collegato a calendario, presenze o statistiche. Le voci "obiettivi" nelle descrizioni di Squadra e Profilo si riferiscono ai badge individuali, non a obiettivi di squadra.
## Cosa faccio
1. **Modello dati locale** in `src/lib/crapp-data.ts`
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
2. **Sezione "Obiettivi di squadra" dentro la scheda Squadra** (`src/routes/squadra.tsx`)
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
- Nessuna nuova rotta e nessuna modifica al bottom nav.
3. **Widget home dinamico** (`src/routes/index.tsx`)
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
4. **Obiettivi demo iniziali**
- 90% di presenze ad agosto (collegato agli eventi di agosto).
- 70% di risposte entro 24h nel prossimo mese.
- Prima vittoria del campionato (collegato allo storico match).
- 5 vittorie in campionato (collegato allo storico match).
- 10 vittorie in campionato (collegato allo storico match).
- 1 evento di squadra al mese (pizzata, ecc.).
## Cosa non cambia
- Resta un prototipo offline: i dati restano in `src/lib/crapp-data.ts`.
- I badge individuali restano come sono in `src/lib/badges.ts` e nella rosa di `src/routes/squadra.tsx`.
- Bottom nav e rotte invariate.
@@ -0,0 +1,58 @@
# Rendere CrAPP operativa: migrazione dal prototipo al Cloud
## Stato attuale
L'app è un prototipo UI con alcune funzioni già collegate a Lovable Cloud:
- Già sul Cloud: voti MVP (`mvp_voti`), iscrizioni push (`push_subscriptions`), sessioni scout (`scout_sessioni`), turni palloni (`turni_palloni`).
- Ancora in locale come dati demo: rosa giocatori, calendario eventi, storico partite, classifica CSI, statistiche individuali, presenze/assenze, badge.
## Cosa serve per renderla operativa
1. **Rosa e profili giocatori sul database**
- Creare tabella `profiles` (o `giocatori`) con nome, numero maglia, ruolo, data di nascita, foto profilo.
- Collegare ogni riga all'utente autenticato corrispondente.
- Rimuovere la rosa statica da `src/lib/crapp-data.ts` e caricarla dal backend.
2. **Autenticazione reale**
- Sostituire la semplice selezione "Chi sei?" in `localStorage` con login email/password o OAuth (Google).
- Ogni giocatore accede con le proprie credenziali e vede solo i propri dati modificabili.
- Necessaria per garantire che uno scout o un voto MVP provenga davvero da quel giocatore.
3. **Calendario eventi persistente**
- Tabella `eventi` con tipo, titolo, data, ora, luogo, avversario, casa/fuori.
- Tabella `presenze` (evento_id, giocatore_id, stato, aggiornato_il).
- I compleanni possono restare derivati dalla data di nascita dei giocatori.
4. **Statistiche e badge dinamici**
- Tabella `statistiche` o `azioni_scout` (evento_id, giocatore_id, tipo azione, valore, creato_il).
- I badge vengono calcolati in tempo reale dalle statistiche accumulate, senza valori fissi in `crapp-data.ts`.
5. **Scout live collegato ai dati reali**
- Le azioni registrate in `scout.tsx` devono scrivere sulle tabelle eventi/statistiche.
- Mantenere il lock di modifica singolo e l'attivazione solo il giorno della partita.
6. **Classifica CSI**
- Tabella `classifica` aggiornata manualmente da un admin o importata dal sito CSI quando disponibile.
- Per ora nessuna API CSI ufficiale: si inserisce a mano o si copia/incolla.
7. **Notifiche push definitive**
- Verificare che i cron job inviino correttamente i promemoria palloni.
- Aggiungere notifiche per conferma eventi, promemoria presenze, MVP votabile.
8. **Ruoli e permessi**
- Definire chi può creare/modificare eventi (capitano/admin).
- Chi può fare scout (designato per partita).
- Chi può modificare i turni palloni (tutti, come da tua richiesta).
## Cosa resta salvato nel Cloud
Sì: tutto ciò che viene scritto sulle tabelle Lovable Cloud/Supabase resta salvato online e condiviso tra tutti i dispositivi.
I dati demo in `src/lib/crapp-data.ts` invece no: sono file statici, quindi ogni aggiornamento dell'app li sovrascrive e ogni telefono li vede identici.
## Decisioni da prendere insieme
- Vuoi abilitare login email/password per ogni giocatore, o preferisci mantenere la selezione "Chi sei?" senza password per semplicità?
- Chi gestirà inserimento eventi e aggiornamento classifica: solo alcuni o tutta la squadra?
- Vuoi procedere per fasi (prima rosa + calendario + presenze, poi statistiche) o tutto insieme?
@@ -0,0 +1,23 @@
# Ridimensionamento badge nella lista squadra
## Obiettivo
Rendere i badge accanto al nome del giocatore nella lista squadra più compatti e meno invasivi, mantenendo lo stile stilizzato (icone Lucide colorate per grado) e lasciando la scheda espansa con una dimensione leggibile.
## Modifiche previste
1. **Lista squadra (`src/routes/squadra.tsx`)**
- Ridurre le icone badge sbloccati mostrate accanto al nome da `h-4 w-4` (16px) a `h-3 w-3` (12px).
- Mantenere il massimo di 3 badge visibili e il contatore `+N` con testo ridotto a `text-[9px]` per armonizzare.
- Lasciare invariati avatar, nome, ruolo ed età.
2. **Scheda giocatore espansa (`src/routes/squadra.tsx`)**
- Portare le icone badge dalla dimensione attuale a `h-4 w-4` (16px), leggibili ma non troppo grandi.
- Mantenere card, colori per grado (bronzo/argento/oro) e testi descrittivi.
3. **Verifica**
- Controllare la preview su `/squadra` per confermare che i badge in lista siano discreti e la scheda espansa rimanga leggibile.
- Eseguire build per assicurarsi che non ci siano errori di tipo o stile.
## Cosa non cambia
- Colori dei gradi, soglie badge, logica di sblocco e votazione MVP.
- Layout generale della pagina e bottom navigation.
@@ -0,0 +1,11 @@
# Rimuovere placeholder "Livello 7" dal Profilo
## Obiettivo
Eliminare il testo statico "Livello 7" dalla scheda profilo, dato che non è collegato a nessun calcolo reale e l'utente preferisce toglierlo per ora.
## Modifica
- `src/routes/profilo.tsx`: rimuovere il paragrafo `<p className="font-display text-2xl leading-none">Livello 7</p>` (riga 116) e, se necessario, riallineare il layout circostante per evitare spazi vuoti strani.
## Verifica
- Build senza errori.
- Preview della pagina Profilo: nessun riferimento a "Livello" visibile.
@@ -0,0 +1,34 @@
# Selezione giocatore al primo avvio
Aggiungere un flusso di onboarding che chiede "Chi sei?" la prima volta che l'app viene aperta, memorizzando la scelta in `localStorage`. Il profilo e la home si aggiorneranno automaticamente in base al giocatore selezionato.
## Cosa cambia
1. **Nuovo store `src/lib/user-store.ts`**
- Persiste in `localStorage` l'`id` del giocatore scelto.
- Espone `useGiocatoreCorrente()` che restituisce il giocatore selezionato o `null`.
- Espone `impostaGiocatore(id)` e `resetGiocatore()`.
2. **Nuova route `/benvenuto`**
- Schermata full-screen con logo, titolo "Benvenuto in CrAPP" e lista scrollabile della rosa.
- Ogni riga mostra iniziali, nome, ruolo e numero maglia.
- Al tap su un giocatore, lo store viene aggiornato e l'utente viene portato a `/`.
- Non mostra la bottom navigation.
3. **Reindirizzamento condizionato in `__root.tsx`**
- Se non è ancora stato selezionato un giocatore, qualunque route apre `/benvenuto`.
- Dopo la scelta, l'app funziona normalmente.
4. **Sostituzione di `giocatoreCorrente` con `useGiocatoreCorrente()`**
- Aggiornare `src/routes/index.tsx` per salutare il giocatore selezionato e mostrarne le statistiche rapide.
- Aggiornare `src/routes/profilo.tsx` per renderlo il profilo personale del giocatore scelto.
5. **Cambio utente dalle impostazioni**
- In `src/routes/profilo.tsx`, aggiungere una voce "Cambia giocatore" che resetta la selezione e porta a `/benvenuto`.
## Note tecniche
- `localStorage` viene letto solo lato client, usando `useSyncExternalStore` per evitare mismatch di hydration.
- La rosa reale è già presente in `src/lib/crapp-data.ts` (`giocatori`).
- La costante esportata `giocatoreCorrente` verrà rimossa; i componenti consumeranno il nuovo hook.
- Nessun backend richiesto: resta un prototipo locale.
@@ -0,0 +1,42 @@
# Turno palloni nel calendario + promemoria
Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra tutti, con rotazione automatica proposta dall'app e promemoria (in-app e push).
## Cosa vedrà la squadra
- **Riga "Palloni" su ogni evento** (allenamento e partita) nella card del calendario e in Home: avatar + nome dell'incaricato, oppure "Da assegnare".
- **Chiunque può cambiarlo**: tocco sulla riga, si apre la lista della rosa, si sceglie il nome. La modifica è immediata e visibile a tutti.
- **Proposta automatica a rotazione**: l'app suggerisce chi non ha ancora fatto il turno di recente (o è stato meno volte incaricato). Il suggerimento è solo una proposta: resta sempre modificabile.
- **Storico turni** nella scheda squadra/giocatore: quante volte ciascuno ha portato i palloni.
- **Due promemoria per l'incaricato**:
1. il giorno stesso dell'evento in cui li deve **prendere** a fine allenamento/partita;
2. il giorno dell'evento successivo, per ricordargli di **riportarli**.
- I promemoria arrivano come banner ben visibile in Home e, per chi attiva le notifiche, come notifica push sul telefono.
## Impostazione tecnica
**Backend (Lovable Cloud)**
- Attivazione di Lovable Cloud.
- Tabella `eventi` (spostando i dati demo attuali su database) o, in alternativa minima, tabella `turni_palloni` con `evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`. Scelgo la seconda per limitare il refactor: gli eventi restano in `crapp-data.ts` finché non si passa a calendario dinamico.
- Tabella `push_subscriptions` (giocatore_id, endpoint, chiavi) per le notifiche.
- Grant espliciti + RLS: lettura e scrittura aperte a tutti gli utenti dell'app (nessun login previsto oggi → policy per `anon` limitate a queste tabelle, nessun dato personale sensibile).
- Server functions in `src/lib/palloni.functions.ts`: `getTurni`, `setTurno`, `suggerisciTurno`.
**Frontend**
- Nuovo componente `TurnoPalloni` usato in `EventoCard` e in Home.
- Lettura via TanStack Query (`ensureQueryData` nel loader, `useSuspenseQuery` nel componente), invalidazione dopo la modifica.
- Banner promemoria in Home basato su data odierna + evento successivo, mostrato solo al giocatore selezionato in `user-store`.
**Notifiche push**
- Service worker dedicato al messaging (separato dalla PWA esistente), chiavi VAPID salvate come secret.
- Schermata in Profilo: "Attiva notifiche palloni" con richiesta di permesso.
- Invio schedulato tramite un endpoint `src/routes/api/public/promemoria-palloni.ts` protetto da secret, richiamato una volta al giorno da un job pianificato (pg_cron).
- Nota: su iPhone le notifiche push funzionano solo se l'app è installata dalla schermata Home.
## Ordine di lavoro
1. Attivare Lovable Cloud e creare tabelle + policy.
2. Server functions + UI del turno palloni (assegnazione manuale condivisa).
3. Rotazione automatica suggerita + storico turni.
4. Banner promemoria in-app.
5. Notifiche push + job giornaliero.
+5
View File
@@ -0,0 +1,5 @@
{
"schemaVersion": 1,
"template": "tanstack_start_ts_current",
"revision": "tanstack_start_ts_current-9e5645c506e5"
}
+10 -152
View File
@@ -1,152 +1,10 @@
# CrAPP — regole per gli assistenti AI
Regole vincolanti per qualsiasi assistente AI (Claude Code, Codex, Cursor, ChatGPT) che lavora
su questo repository. Valgono integralmente; `CLAUDE.md` le richiama e non le ripete.
CrAPP è una PWA per la gestione di una squadra di pallavolo. Deve ridurre il lavoro degli
amministratori, aumentare il coinvolgimento dei giocatori, centralizzare le informazioni della
squadra e usare l'AI solo quando porta un beneficio reale. Deve restare semplice, veloce e
usabile dallo smartphone anche da chi non è pratico.
## Prima di modificare il codice
1. Leggi l'indice [docs/README.md](docs/README.md) e segui l'ordine di lettura che indica; poi
il documento del modulo interessato in [docs/modules/](docs/modules/). Le decisioni
([docs/decisions/](docs/decisions/README.md)) si leggono una per una, solo quelle che il modulo
richiama o che l'indice indica per il tema: non servono tutte. Per i termini del dominio c'è il
[glossario](docs/GLOSSARIO.md); le «Trappole note» in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) vanno lette prima di toccare codice o schema.
2. Verifica lo stato attuale del repository: commit recenti, modifiche non committate, lavoro
introdotto da altri collaboratori o da altri assistenti.
3. Non presumere che il progetto sia come l'hai lasciato nell'ultima sessione: la fonte di
verità è il repository, non la cronologia della conversazione.
Non implementare funzionalità non documentate: prima si documenta
([DD-002](docs/decisions/DD-002.md)), poi si scrive il codice.
## Comandi
Variabili d'ambiente, ambienti e rilascio sono in [docs/OPERATIONS.md](docs/OPERATIONS.md).
Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
`minimumReleaseAgeExcludes` richiede conferma esplicita dell'utente.
```bash
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
npm run format # prettier --write .
npm run test # suite di test (test/); npm run test:all per quella completa
npx supabase start # database locale in Docker (migration applicate + seed)
npx supabase db reset # ricrea il database locale da zero
npx supabase db push # applica le migration al progetto cloud
```
## Test
**Chi aggiunge o modifica una funzione scrive anche il test.** Non è opzionale e non si
rimanda: una funzione nuova senza test non è finita, una funzione modificata il cui test non
copre più il comportamento nuovo va aggiornata nello stesso lavoro.
- I test devono **risultare verdi**: non si consegna con test rossi, non si commenta un test
che fallisce e non si indebolisce un'asserzione per farla passare. Se un test rosso segnala
un comportamento voluto che è cambiato, si aggiorna il test spiegando perché.
- La logica di dominio pura sta in `src/lib/` ed è quella da coprire in `test/unit/`: se una
funzione è difficile da testare perché mischia calcolo e hook, separala (`*-core.ts`) come
già fatto per palloni e pagelle.
- Convenzioni, struttura delle cartelle e comandi in [test/README.md](test/README.md).
- Se il comportamento cambia, cambia anche la documentazione: modulo in
[docs/modules/](docs/modules/), più i file elencati in Tracciabilità.
## Fine lavoro
Prima di dire che hai finito:
1. i test delle funzioni aggiunte o modificate esistono e sono verdi;
2. `npm run lint`, `npm run typecheck` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
3. la documentazione toccata dalla modifica è aggiornata (vedi Test e Tracciabilità);
4. hai detto all'utente cosa hai cambiato, cosa hai lasciato fuori e quali rischi vedi.
## Git
`main` è la versione in produzione: qualsiasi commit deve lasciare l'app funzionante.
`develop` pubblica una preview Vercel, ma oggi è fermo indietro rispetto a `main` e non
rappresenta lo stato attuale (DD-019). I branch `feature/…`, `fix/…`, `refactor/…` servono per
lavori paralleli o rischiosi.
**È l'utente a decidere su quale branch va un commit.** L'assistente può consigliare un branch
dedicato quando la modifica è rischiosa o parallela ad altro lavoro, ma non cambia branch né
apre PR di propria iniziativa. In assenza di indicazioni si lavora dove si trova il repository.
Non committare, non fare push e non aprire PR senza che l'utente lo abbia chiesto.
## Tracciabilità
Ogni modifica significativa deve lasciare una traccia leggibile senza la cronologia delle
conversazioni: commit con messaggio descrittivo, più il documento giusto tra
[docs/CHANGELOG.md](docs/CHANGELOG.md) (cosa è stato rilasciato e quando),
[PROJECT_STATE.md](PROJECT_STATE.md) (a che punto siamo ora, in una pagina: non il registro delle
migration né l'elenco delle funzioni),
[docs/decisions/](docs/decisions/README.md) (decisioni architetturali, un file `DD-XXX.md` ciascuna),
[docs/ROADMAP.md](docs/ROADMAP.md), [docs/DATABASE.md](docs/DATABASE.md) (se cambia lo schema),
[docs/OPERATIONS.md](docs/OPERATIONS.md) (se cambiano variabili d'ambiente o procedure di rilascio).
Una decisione nuova è un file `docs/decisions/DD-XXX.md` copiato da `docs/_template-dd.md`, più la
riga nell'indice. Per aggiungere una funzione o un modulo segui la checklist in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
Quali contenuti vanno in quale file, e le convenzioni di scrittura, stanno nelle regole di
manutenzione di [docs/README.md](docs/README.md): ogni informazione ha una sola casa, non
duplicarla altrove.
## Database
Il database è Supabase; lo schema documentato sta in [docs/DATABASE.md](docs/DATABASE.md),
allineato alle migration in `supabase/migrations/`.
- Ogni modifica allo schema è una **nuova** migration: le migration già applicate sono storia
e non si riscrivono.
- Non eliminare tabelle esistenti, non modificare lo schema senza motivazione.
- Ordine: progetta → documenta → crea la migration → testala in locale (`npx supabase db reset`)
→ verifica l'assenza di regressioni → solo dopo applicala in produzione.
- Preferisci strutture scalabili, evita duplicazione dei dati.
## Codice e interfaccia
L'architettura tecnica (stack, struttura delle cartelle, punti fermi da non rompere) sta in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): leggila prima di toccare routing, `vite.config.ts`,
client Supabase o autenticazione.
Componenti piccoli, riutilizzabili, a responsabilità singola. Prima di crearne uno nuovo,
verifica se esiste già in `src/components/`. L'interfaccia resta semplice, moderna, veloce,
ottimizzata per smartphone: poche schermate, pochi click, stile coerente con l'esistente.
## Regola anti-regressione
Le nuove versioni aggiungono funzionalità. Non riscrivere moduli già funzionanti senza una
motivazione esplicita, e non fare refactoring trasversali mentre sviluppi altro. Prima di
modificare un modulo esistente verifica quali altre parti dell'app lo usano.
## L'AI non deve
- introdurre librerie senza necessità, né aggirare `minimumReleaseAge`;
- modificare il database o il comportamento dell'app senza richiesta esplicita;
- eliminare funzionalità esistenti;
- sovrascrivere modifiche di altri collaboratori senza averne compreso lo scopo;
- riscrivere migration già applicate;
- committare, pushare o cambiare branch di propria iniziativa.
## L'AI deve
- spiegare le modifiche importanti e segnalare rischi, conflitti e possibili regressioni
**prima** di toccare parti sensibili;
- mantenere la compatibilità con il codice esistente e riutilizzare i componenti;
- privilegiare la semplicità;
- tenere aggiornata la documentazione quando serve.
## Filosofia
Prima di scrivere codice: questa modifica rende CrAPP più semplice? Riduce il lavoro degli
amministratori? Migliora l'esperienza dei giocatori? È coerente con la documentazione? Riduce
o aumenta la complessità futura? Se almeno una risposta è negativa, rivaluta la soluzione.
<!-- LOVABLE:BEGIN -->
> [!IMPORTANT]
> This project is connected to [Lovable](https://lovable.dev). Avoid rewriting
> published git history — force pushing, or rebasing/amending/squashing commits
> that are already pushed — as it rewrites history on Lovable's side and the
> user will likely lose their project history.
>
> Commits you push to the connected branch sync back to Lovable and show up in
> the editor, so keep the branch in a working state.
<!-- LOVABLE:END -->
+126 -6
View File
@@ -1,9 +1,129 @@
# CLAUDE.md
Le regole di progetto stanno in @AGENTS.md: valgono integralmente e non sono ripetute qui —
compresi i comandi (`npm run dev/lint/test`, supabase) e la checklist «Fine lavoro» da eseguire
prima di dire che hai finito. La documentazione tecnica è indicizzata in
[docs/README.md](docs/README.md); l'architettura in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**Non aggiungere regole in questo file.** Una regola nuova va in `AGENTS.md`, che leggono
anche Codex e Cursor; scritta qui la vedrebbe solo Claude Code.
## What this is
CrAPP — a mobile-first web app for managing an amateur volleyball team (CRAP Volley): attendance,
matches/trainings/events, live match scouting, player stats/badges, CSI league standings, push
notifications. Italian-language codebase (routes, variables, comments). Built with Lovable
(lovable.dev); pushes to `main` sync back into the Lovable editor — avoid rewriting published git
history (force-push, rebase/amend/squash of pushed commits).
## Commands
Package manager is **bun** (`bun.lock` is the lockfile; `package-lock.json` also exists but bun is
primary — see `bunfig.toml`).
```sh
bun run dev # vite dev server
bun run build # production build (nitro/vite)
bun run build:dev # development-mode build
bun run preview # preview a build
bun run lint # eslint .
bun run format # prettier --write .
```
There is no test runner configured in this repo.
Adding a dependency: `bunfig.toml` enforces a 24h supply-chain guard (`minimumReleaseAge`) on new
packages; only pre-listed `@lovable.dev/*` packages bypass it. Confirm with the user before adding
new bypass entries.
## Architecture
**Stack**: TanStack Start (React 19, file-based router) + Vite, styled with Tailwind v4 +
shadcn/radix components, data via `@supabase/supabase-js`, TanStack Query for client cache.
### Routing
File-based routing under `src/routes/` (see `src/routes/README.md`). One root layout,
`src/routes/__root.tsx`, wraps every page — preserve its `<Outlet />`. `$id.tsx` = dynamic segment,
`{-$category}.tsx` = optional segment, `$.tsx` = splat (`_splat` param). Do not hand-create
`src/pages/` or Next/Remix-style layout files. `src/routeTree.gen.ts` is auto-generated — never
edit it directly.
Server-only HTTP endpoints live under `src/routes/api/public/*.ts` (e.g. push subscription,
palloni/presenze reminders) — plain HTTP handlers, not proprietary edge functions, so they can run
under any Node host or scheduler (system cron, pg_cron, etc).
### Server entry / SSR error handling
`src/start.ts` registers global middleware: `attachSupabaseAuth` (client-side function middleware
that attaches the Supabase bearer token to every server-fn RPC — see
`src/integrations/supabase/auth-attacher.ts`, which is **auto-generated**, do not hand-edit) and an
error middleware + explicit CSRF middleware (`createCsrfMiddleware`) for server functions. Defining
`src/start.ts` opts out of Start's automatic CSRF middleware, so this file must keep re-adding it.
`src/server.ts` wraps the generated TanStack Start server entry and normalizes a specific h3
failure mode: h3 swallows in-handler throws into a 500 JSON body
(`{"unhandled":true,"message":"HTTPError"}`) that a plain try/catch never sees, so it's detected by
inspecting the response body and converted into `renderErrorPage()`'s HTML error page.
### Data layer — portability rule
The project must remain deployable on plain Node.js + PostgreSQL, not locked into Lovable Cloud
(see `docs/PORTABILITA.md`). Concretely:
- **Never query the database from components.** All data access goes through modules in
`src/lib/*.ts` (e.g. `palloni.ts`, `mvp-voti.ts`, `eventi.ts`, `presenze.ts`, `rosa.ts`,
`scout-*.ts`) — each exports TanStack Query hooks (`useX`) wrapping `supabase.from(...)`. This
keeps the backend swappable in one place.
- `src/integrations/lovable/*` (social login) is optional and unused by any screen — safe to
remove without impact.
- No provider-exclusive features: no edge functions, no Lovable-only auth as the sole login method,
no proprietary storage. Config only via standard env vars (`DATABASE_URL` / `SUPABASE_URL` +
keys, `VAPID_*`), never hardcoded.
- SQL migrations in `supabase/migrations/` must use standard PostgreSQL, no provider-exclusive
extensions.
### Cloud-efficiency rules (small paid-tier budget)
The Supabase/Lovable Cloud plan is metered (~20 credits/month, ~17 users), so these constraints are
load-bearing, not style preferences:
- No polling (`refetchInterval`) against the database; prefer local sync via
BroadcastChannel/storage.
- Global QueryClient (`src/router.tsx`) is deliberately configured for infrequent refetching:
`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` all off, `retry: 1`.
Don't override this per-query without a reason.
- After a mutation, update the query cache with `setQueryData` (see `useAssegnaTurno` in
`src/lib/palloni.ts` for the pattern) rather than `invalidateQueries`, to avoid an extra read.
- Stats/badges/standings are "write once, read many": computed and persisted once (e.g. at match
end), never recomputed on every page open.
- Live match scouting: only the scoring user writes; everyone else reads already-saved data.
- CSI league data is currently a **hardcoded placeholder** array in `src/lib/crapp-data.ts`
(`classifica`, `storicoMatch`), explicitly marked "(demo)" in the UI — no real sync exists yet,
this is planned/open work, not an existing pattern to copy.
- A scouted match result (`ScoutMatch`, from `scout.tsx` / `scout-store.ts`) is saved **only to
the scoring device's `localStorage`** (key `crapp-scout-v1`), never to Supabase — only the
in-progress resume state goes to the `scout_live` table. So today a match result is not shared
across devices/users at all.
- Push notifications only for high-value events (convocations, training/match reminders, ball-duty
turn, final result) — no chat/photo/video features.
### Auth
Supabase Auth (GoTrue), attached via middleware rather than per-request boilerplate — see
`auth-attacher.ts` / `auth-middleware.ts` above. Self-hostable; not tied to Lovable-proprietary
auth.
## Project memory (`mem/`)
`mem/index.md` indexes feature-level design notes (`mem/features/*.md`) — currently the portability
rule and the cloud-efficiency rules summarized above. Check there before adding a feature that
might conflict with either constraint.
## Conventions
- **Language: Italian.** Code comments and git commit messages must be written in Italian, matching
the rest of the codebase (routes, identifiers, existing comments/commits are already Italian).
- Path alias `@/*` → `src/*` (see `tsconfig.json`).
- Prettier: 100-char width, double quotes (`singleQuote: false`), trailing commas everywhere.
- TypeScript strict mode plus extra strictness: `noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, `noImplicitReturns`, `noImplicitOverride`,
`noPropertyAccessFromIndexSignature`.
- `vite.config.ts` is intentionally minimal: `@lovable.dev/vite-tanstack-config` already bundles
TanStack devtools, `tanstackStart`, `viteReact`, `tailwindcss`, `tsConfigPaths`, nitro, env
injection, and the `@` alias — do not re-add any of those plugins manually or the app breaks with
duplicate plugins.
-14
View File
@@ -1,14 +0,0 @@
Copyright (c) 2026 Ivan Cacciari e Davide Grilli. Tutti i diritti riservati.
Il presente software e la relativa documentazione (il "Software") sono di
proprietà esclusiva di Ivan Cacciari e Davide Grilli.
Non è concessa alcuna licenza d'uso, salvo autorizzazione scritta esplicita
dei titolari. È vietato copiare, modificare, distribuire, sublicenziare,
vendere, pubblicare o comunque sfruttare in tutto o in parte il Software,
in qualsiasi forma o con qualsiasi mezzo, senza il preventivo consenso
scritto dei titolari.
IL SOFTWARE È FORNITO "COSÌ COM'È", SENZA GARANZIE DI ALCUN TIPO, ESPLICITE
O IMPLICITE. I TITOLARI NON SONO RESPONSABILI PER QUALSIASI DANNO DERIVANTE
DALL'USO O DALL'IMPOSSIBILITÀ DI USO DEL SOFTWARE.
-53
View File
@@ -1,53 +0,0 @@
# Project State
Ultimo aggiornamento: 02/11/2026
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
[docs/OPERATIONS.md](docs/OPERATIONS.md). Non ripetere qui quello che hanno già loro.
## Stato generale
- **Versione:** 1.6.2 su `main`: layout corretto sugli schermi stretti (360-402 px) e
Ace, Attacchi, Muri e Punti dai + dello scout in Squadra e Profilo, senza migration. La 1.6.1
(tag `v1.6.1`): risultati dello scout leggibili nella pagina partita, classifica cacche
completa e pagina partita a sezioni a scomparsa. La 1.6.0
(tag `v1.6.0`, in produzione dal 02/11/2026): voto MVP definitivo, segreto e a
tempo (DD-051, DD-052) e Scout live a valutazioni − / + (DD-053). La 1.5.0: campionato CSI sulla stagione 2026/27 (nuovi `project_id` e `team_id`,
nessuna Coppa). La 1.4.2: ogni evento può avere un link Google Maps del luogo, mostrato come
«Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con convocati si
vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0:
avviso «Presenza modificata» ad admin e allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario
mostra solo i prossimi compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta
lampo» e «Mai un forfait». La 1.3.0 portava la notifica di compleanno, gli obiettivi cliccabili, le
presenze sui convocati, gli avvisi in Home ridisegnati e i caratteri più grandi. Dettaglio in
[docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 50 file in `supabase/migrations/`, fino a `m39_scout_valutazioni`. In produzione sono
applicate tutte e 50 (`npx supabase db push` del 02/11/2026: `m35`-`m39`, verificato con
`npx supabase migration list --linked`; nessuna migration remota è assente dal repository). Prima
del push è stato salvato lo schema di produzione (solo schema, non i dati).
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
è su `main` dalla 1.3.0.
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
collega al primo accesso (DD-018).
- **Lavoro in corso:** nessuno su branch. Le voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto
«Prossimo». Da decidere sullo scout: il pulsante «Annulla» in alto agisce solo sulle azioni, che non
si creano più, quindi è sempre spento.
## Cose da sapere
- Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo.
- Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno
slot lo occupa anche in produzione e va liberato da un admin.
- Lo slot `g18` «Beta Tester» è un giocatore in produzione (dal 09/10/2026, prima era allenatore), numero 99 e
ruolo «Palleggiatore»; non è collegato a nessun account. Serve per provare le notifiche con un secondo utente.
## Note
Il progetto segue una metodologia document-first (DD-002): ogni funzionalità si progetta in
`docs/modules/` prima di essere implementata.
+171 -39
View File
@@ -1,59 +1,191 @@
# CrAPP 🏐
# CrAPP — CRAP Volley Hub
CrAPP è una Progressive Web App sviluppata per digitalizzare completamente la gestione di una squadra di pallavolo.
App mobile-first per la gestione della squadra amatoriale di pallavolo **CRAP Volley**:
presenze, partite/allenamenti/eventi, scouting live, statistiche giocatori, badge,
classifica CSI, notifiche push.
## Funzionalità principali
Progetto avviato con [Lovable](https://lovable.dev): i push su `main` sincronizzano l'editor
Lovable, quindi si evita di riscrivere la history già pubblicata (niente force-push,
rebase/amend/squash di commit già pushati).
- Gestione squadra
- Gestione presenze
- Calendario allenamenti e partite
- Scout Live
- Badge e gamification
- Statistiche
- Notifiche intelligenti
- Gestione amministrativa
- AI per la pianificazione degli allenamenti (in sviluppo)
**Live app**: https://volley-cronos-app.lovable.app
## Stack tecnologico
## Stack
React 19, TypeScript, TanStack Start (SSR), Vite 8, Tailwind CSS 4, motion, vaul,
Supabase (PostgreSQL, Auth, Storage), Vercel, GitHub. Dettagli in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
- [TanStack Start](https://tanstack.com/start) (React 19, routing file-based) + Vite
- Tailwind v4 + componenti shadcn/radix
- [Supabase](https://supabase.com) (`@supabase/supabase-js`) per dati e auth
- TanStack Query per la cache client
## Avvio locale
## Funzionalità
Le dipendenze si installano con **bun** (`bun.lock`):
- **Presenze**: RSVP rapido (presente / assente / forse / in ritardo / infortunato) su ogni evento
- **Eventi**: partite, allenamenti, eventi sociali, con calendario e lista
- **Scouting live**: registrazione azioni punto-per-punto durante la partita (riservato agli
admin)
- **Statistiche giocatori**: presenze totali/consecutive, badge, obiettivi squadra, medie pagelle
- **Votazioni post-partita**: MVP, pagelle 1-10 tra compagni, voti "social"/goliardici
- **Turno palloni**: rotazione automatica di chi porta i palloni, con promemoria push
- **Classifica CSI**: al momento un dato demo hardcoded (vedi [CLAUDE.md](CLAUDE.md)), sync reale
ancora da implementare
- **Notifiche push**: convocazioni, promemoria allenamento/partita, turno palloni, esito finale
```bash
## Sviluppo (locale)
Package manager: **bun** ([installazione](https://bun.sh)).
```sh
git clone https://github.com/ivancacciari1995-a11y/CRAPP.git
cd CRAPP
bun install
npm run dev # http://localhost:8080
```
## Comandi
### 1. Avvia un backend Supabase
```bash
npm run build # build di produzione
npm run lint # eslint (include prettier)
npm run typecheck # controllo dei tipi TypeScript
npm run test # test unit; npm run test:all per la suite completa
Serve un'istanza Supabase (cloud o self-hosted) a cui puntare. Per lavorare in locale senza
toccare dati reali, nel repo c'è uno stack self-hosted pronto in `infra/supabase/docker/`:
```sh
cd infra/supabase/docker
cp .env.example .env
sh utils/generate-keys.sh --update-env # genera JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, ecc.
```
Chi aggiunge o modifica una funzione scrive anche il test e lo lascia verde
([test/README.md](test/README.md)).
Poi avvia lo stack (con `docker-compose.prod.yml` si tengono su solo i servizi che CrAPP usa
davvero — vedi la sezione Produzione più sotto):
## Deploy
```sh
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
docker compose ps # verifica che tutti i container siano "healthy"
```
Deploy automatico su Vercel a ogni push su `main`, che è anche il branch di lavoro corrente.
`develop` pubblica un Preview Deployment, ma oggi è indietro rispetto a `main`. Su quale branch
committare lo decide chi sviluppa (DD-019).
Applica le migrazioni del progetto al database appena creato:
## Variabili d'ambiente
```sh
cd ../../.. # torna alla root del repo
for f in supabase/migrations/*.sql; do
docker exec -i supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < "$f"
done
```
Per far partire l'app servono `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `VITE_SUPABASE_URL` e
`VITE_SUPABASE_PUBLISHABLE_KEY`. L'elenco completo (service role, VAPID, SMTP del worker) sta in
[docs/OPERATIONS.md](docs/OPERATIONS.md).
### 2. Configura `.env` di CrAPP
## Documentazione
Nella root del repo, crea `.env` con le chiavi lette da `infra/supabase/docker/.env`:
Indice in [docs/README.md](docs/README.md). Le regole per gli assistenti AI stanno in
[AGENTS.md](AGENTS.md), lo stato corrente del lavoro in [PROJECT_STATE.md](PROJECT_STATE.md).
```
VITE_SUPABASE_URL=http://localhost:8000
VITE_SUPABASE_PUBLISHABLE_KEY=<ANON_KEY dello stack self-hosted>
SUPABASE_SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY dello stack self-hosted>
```
Facoltative per testare le notifiche push (senza non si rompe nulla, semplicemente niente push):
```
VAPID_PUBLIC_KEY=...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:tuamail@esempio.it
```
### 3. Avvia l'app
```sh
bun run dev # http://localhost:8080
```
Le tabelle sono vuote al primo avvio: crea un giocatore/evento dall'app stessa per avere dati di
test. Dashboard Supabase Studio raggiungibile su `http://localhost:8000` (credenziali
`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD` in `infra/supabase/docker/.env`).
Altri comandi:
```sh
bun run build:dev # build in modalità development
bun run preview # anteprima di una build
bun run lint # eslint .
bun run format # prettier --write .
```
Non è configurato un test runner.
## Produzione (self-hosted, Node + Docker)
`bun run build` usa Nitro (tramite `@lovable.dev/vite-tanstack-config`), che di default
compilerebbe per Cloudflare Workers — in [vite.config.ts](vite.config.ts) il preset è forzato a
`node-server` per generare un server Node standard, deployabile su qualunque host (coerente con
[docs/PORTABILITA.md](docs/PORTABILITA.md)).
Passo passo, sul server di produzione:
0. **Dominio/DNS**: un solo hostname pubblico basta — `Caddyfile.example` instrada `/api/*`
verso il gateway Supabase (path stripped) e tutto il resto verso l'app, sullo stesso dominio.
- Con un dominio vero (DNS proprio): `tuodominio.it`.
- Con un dominio **gratuito No-IP** (`ddns.net`, ecc.): un hostname singolo basta e avanza
(il piano free ne dà fino a 3, ma qui ne serve uno solo), es. `crapp.ddns.net`.
- Serve un client DDNS attivo sul server (o supporto nel router) che aggiorni l'IP: gli
hostname No-IP gratuiti scadono dopo ~30 giorni di inattività se non confermati.
- Verifica di non essere dietro **CGNAT** (IP pubblico condiviso dall'ISP): in quel caso
nessuna porta è raggiungibile da internet e serve un tunnel (es. Cloudflare Tunnel) al
posto dell'esposizione diretta.
1. **Stack Supabase self-hosted**:
```sh
cp infra/supabase/docker/.env.production.example infra/supabase/docker/.env
cd infra/supabase/docker
sh utils/generate-keys.sh --update-env # secret NUOVI, non riusare quelli di sviluppo
```
Modifica nel `.env` appena creato: `SUPABASE_PUBLIC_URL`/`API_EXTERNAL_URL` (dominio del
punto 0 + `/api`), `SITE_URL` (dominio del punto 0), `DASHBOARD_PASSWORD`, e l'SMTP se
servono email vere. Poi avvia:
```sh
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
`docker-compose.prod.yml` fa due cose, pensate anche per girare su hardware limitato (es.
Raspberry Pi 4): non espone porte pubblicamente (solo `127.0.0.1`, dietro il reverse proxy —
vedi `Caddyfile.example` nella stessa cartella), e disattiva i servizi Supabase che CrAPP non
usa (Realtime, Storage, imgproxy, Edge Functions, pooler) — restano solo `db`, `auth`, `rest`,
`api-gw`, più `studio`+`meta` per guardare/gestire il database via interfaccia grafica
(raggiungibile su `<dominio>/api`, con le credenziali `DASHBOARD_USERNAME`/
`DASHBOARD_PASSWORD`). Da 11 container si scende a 6.
2. **Migrazioni**:
```sh
cd ../.. # root del repo
for f in supabase/migrations/*.sql; do
docker exec -i supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < "$f"
done
```
3. **Reverse proxy TLS**: copia `infra/supabase/docker/Caddyfile.example` in `Caddyfile`,
sostituisci il dominio del punto 0, poi `caddy run --config Caddyfile` (o come container).
Gestisce automaticamente il certificato Let's Encrypt.
4. **Env dell'app**:
```sh
cp .env.production.example .env
```
Compila `VITE_SUPABASE_URL` (dominio del punto 0 + `/api`),
`VITE_SUPABASE_PUBLISHABLE_KEY` (= `ANON_KEY` dello stack), `SUPABASE_SERVICE_ROLE_KEY`
(= `SERVICE_ROLE_KEY`), e le chiavi `VAPID_*` reali (`npx web-push generate-vapid-keys`).
5. **Build e avvio**:
```sh
bun run build
node .output/server/index.mjs # ascolta su PORT (default 3000)
```
Tienilo vivo con un process manager (pm2/systemd) — il Caddy del punto 3 lo espone via TLS
sull'hostname app.
6. **Job pianificati** — gli endpoint `src/routes/api/public/promemoria-palloni.ts` e
`sollecita-presenze.ts` vanno richiamati periodicamente via HTTP POST da un cron di sistema o
`pg_cron`: non partono da soli.
## Continuare da Lovable
Il progetto resta modificabile anche dall'[editor Lovable](https://lovable.dev/projects/8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c):
le modifiche fatte lì vengono committate direttamente su questo repository, e viceversa i push
su `main` sincronizzano l'editor.
## Portabilità
Il progetto è pensato per restare deployabile su un normale server **Node.js + PostgreSQL**,
senza dipendenze esclusive da Lovable Cloud — vedi [docs/PORTABILITA.md](docs/PORTABILITA.md)
per lo stato attuale e le regole da rispettare.
## Note per chi sviluppa con Claude Code
Vedi [CLAUDE.md](CLAUDE.md) per architettura dettagliata, convenzioni del repo e vincoli di
efficienza sul piano cloud a consumo.
-132
View File
@@ -1,132 +0,0 @@
# Backup di disaster recovery
Script per scaricare su questo host un backup completo del progetto Supabase:
schema DB, dati, utenti auth e file storage. Serve per poter ripartire in fretta
se il progetto Supabase (o Vercel) venisse perso.
## Preparazione, passo per passo
Da fare una volta sola per host (poi il backup si rilancia semplicemente con
`./backups/backup.sh`).
1. **Installa/aggiorna la CLI Supabase** (via `npx`, non serve installarla a parte):
```bash
npx supabase --version
```
2. **Accedi con il tuo account Supabase**:
```bash
npx supabase login
```
Si apre il browser per l'OAuth; il token resta salvato in locale.
3. **Collega il repo al progetto Supabase** (il `project_id` è già in
`supabase/config.toml`, quindi in genere basta):
```bash
npx supabase link --project-ref <project_id>
```
Verifica che sia andato a buon fine con `npx supabase projects list`
(il progetto deve comparire con `"linked":true`).
4. **Crea/controlla il file `.env`** nella root del repo (accanto a
`package.json`, non dentro `backups/`) con almeno queste due chiavi:
```
VITE_SUPABASE_URL="https://<project_id>.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="<service role key>"
```
Le trovi su [supabase.com/dashboard](https://supabase.com/dashboard) → progetto →
Project Settings → API. La **service role key** è quella con permessi pieni
(bypassa le RLS): serve solo per scaricare i file dai bucket, non va mai
esposta lato client né committata.
5. **Verifica che gpg sia installato** (serve per cifrare, di default):
```bash
gpg --version
```
Su Debian/Ubuntu: `sudo apt install gnupg` se manca.
A questo punto sei pronto per lanciare il backup vero e proprio (vedi sotto).
## Uso
```bash
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/, poi comprime e cifra
# in backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
# passphrase a te scelta) e cancella cartella+tar.gz
# in chiaro
./backups/backup.sh --no-encrypt # come sopra ma lascia solo il tar.gz in chiaro
# (nessuna passphrase richiesta)
```
La passphrase non è salvata da nessuna parte: la scegli tu al momento (gpg la
chiede due volte, per conferma) e ti serve identica per decifrare in seguito —
se la perdi, il backup cifrato è irrecuperabile. Tienila in un password manager.
Ogni esecuzione crea una cartella/archivio nuovo con timestamp: non sovrascrive
backup precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a
mano quando non servono più.
Se un passaggio fallisce (un dump o anche un solo file dello storage non scaricato),
lo script si ferma con un errore e **non** stampa «Backup completato»: un backup
incompleto non va considerato valido, rilancialo.
Il backup contiene:
| File/cartella | Contenuto |
| ------------------- | ---------------------------------------------------------------------------------------------------------- |
| `schema_public.sql` | schema delle tabelle applicative (tabelle, funzioni, RLS) |
| `data_public.sql` | dati delle tabelle applicative |
| `schema_auth.sql` | schema utenti Supabase Auth |
| `data_auth.sql` | utenti reali, incluso l'hash della password |
| `storage/` | tutti i file dei bucket (`avatar-giocatori`, `profili-giocatore`: foto, documenti d'identità, certificati) |
## Decomprimere
Archivio cifrato (caso di default), chiede la passphrase scelta al momento
del backup:
```bash
gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz -C backups/
```
Ricrea `backups/AAAAMMGG_hhmm/` con dentro i file elencati sopra.
Archivio non cifrato (generato con `--no-encrypt`):
```bash
tar xzf backups/AAAAMMGG_hhmm.tar.gz -C backups/
```
## Ripristino
1. Nuovo progetto Supabase (o `supabase start` in locale via Docker).
2. Importa nell'ordine: `schema_public.sql` → `schema_auth.sql` →
`data_auth.sql` → `data_public.sql` (gli utenti auth prima dei dati
pubblici, perché `giocatori.auth_user_id` referenzia `auth.users.id`).
3. Ricrea i bucket `avatar-giocatori` e `profili-giocatore` e ricarica il
contenuto di `storage/`.
4. Reimposta le variabili d'ambiente su Vercel/locale (non incluse in questo
backup — vedi sotto).
5. Redeploy dal repo git (il codice non serve backupparlo qui, è già su
GitHub e Gitea).
## Cosa NON è incluso
- **Variabili d'ambiente Vercel**: richiede `vercel login` interattivo, non
automatizzabile da questo script. Esportale a mano con `vercel env pull`.
- **Codice applicativo**: vive nel repo git, non nel database.
## Sicurezza
Questi backup contengono password hash, documenti d'identità e altri dati
personali reali. Per questo:
- la cartella `backups/` è in `.gitignore` (tranne questo README e gli script:
quelli sì restano in git) — **non committare mai i dati generati**;
- se sposti un backup fuori da questo host, usa l'archivio cifrato (default)
invece del tar.gz in chiaro generato con `--no-encrypt`;
- cancella i backup in chiaro quando non ti servono più.
## File di questa cartella
| File | Ruolo |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| `backup.sh` | Punto di ingresso: esegue dump e download in ordine |
| `download-storage.mjs` | Scarica i file dei bucket (usa la service role key del `.env`) |
| `storage-core.ts` | Logica pura (lettura del `.env`, elenco dei file), coperta da `test/unit/backup-storage.test.ts` |
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env bash
# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage.
# Uso:
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/, comprime e cifra con gpg (chiede una passphrase)
# ./backups/backup.sh --no-encrypt come sopra ma lascia solo il tar.gz in chiaro
#
# Richiede: essere già collegati al progetto Supabase (supabase login + link, già fatto
# in questo repo), avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY, e bun
# installato (esegue download-storage.mjs, che importa un modulo .ts).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STAMP="$(date +%Y%m%d_%H%M)"
RUN_DIR="$SCRIPT_DIR/$STAMP"
mkdir -p "$RUN_DIR"
echo "==> Backup in $RUN_DIR"
echo "==> Dump schema pubblico"
npx supabase db dump --linked -f "$RUN_DIR/schema_public.sql"
echo "==> Dump dati pubblici"
npx supabase db dump --linked --data-only -f "$RUN_DIR/data_public.sql"
echo "==> Dump schema auth"
npx supabase db dump --linked --schema auth -f "$RUN_DIR/schema_auth.sql"
echo "==> Dump dati auth (utenti)"
npx supabase db dump --linked --schema auth --data-only -f "$RUN_DIR/data_auth.sql"
echo "==> Download file storage (bucket)"
bun "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage"
echo "==> Backup completato: $RUN_DIR"
TARBALL="$RUN_DIR.tar.gz"
echo "==> Comprimo in $TARBALL"
tar -C "$SCRIPT_DIR" -czf "$TARBALL" "$STAMP"
if [[ "${1:-}" == "--no-encrypt" ]]; then
echo "==> Archivio pronto: $TARBALL"
else
ARCHIVE="$TARBALL.gpg"
echo "==> Cifro in $ARCHIVE (verrà chiesta una passphrase)"
gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE" "$TARBALL"
rm -rf "$RUN_DIR" "$TARBALL"
echo "==> Archivio cifrato pronto: $ARCHIVE"
echo " Per estrarlo: gpg -d $ARCHIVE | tar xz"
fi
-45
View File
@@ -1,45 +0,0 @@
// Scarica tutti i file dei bucket Supabase Storage per il backup di disaster recovery.
// Uso: node backups/download-storage.mjs [cartella-output]
import { createClient } from "@supabase/supabase-js";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { elencaFile, leggiEnv } from "./storage-core.ts";
const env = leggiEnv(await readFile(new URL("../.env", import.meta.url), "utf8"));
for (const [chiave, valore] of Object.entries(env)) process.env[chiave] ??= valore;
const BUCKETS = ["avatar-giocatori", "profili-giocatore"];
const OUT_DIR = process.argv[2] ?? new URL("./storage/", import.meta.url).pathname;
const supabase = createClient(process.env.VITE_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
const elencaCartella = (bucket) => async (prefisso) => {
const { data, error } = await supabase.storage.from(bucket).list(prefisso, { limit: 1000 });
if (error) throw error;
return data;
};
const falliti = [];
for (const bucket of BUCKETS) {
console.log(`Bucket: ${bucket}`);
const files = await elencaFile(elencaCartella(bucket));
console.log(` ${files.length} file trovati`);
for (const path of files) {
const { data, error } = await supabase.storage.from(bucket).download(path);
if (error) {
console.error(` ERRORE ${path}:`, error.message);
falliti.push(`${bucket}/${path}`);
continue;
}
const destPath = join(OUT_DIR, bucket, path);
await mkdir(dirname(destPath), { recursive: true });
await writeFile(destPath, Buffer.from(await data.arrayBuffer()));
console.log(` scaricato: ${bucket}/${path}`);
}
}
if (falliti.length) {
console.error(`${falliti.length} file non scaricati: il backup storage è incompleto.`);
process.exit(1);
}
console.log("Fatto.");
-30
View File
@@ -1,30 +0,0 @@
/** Logica pura del backup storage, separata dalla rete per poterla testare. */
/** Righe `CHIAVE=valore` di un file `.env`; toglie le virgolette solo se aprono e chiudono. */
export function leggiEnv(testo: string): Record<string, string> {
const env: Record<string, string> = {};
for (const riga of testo.split("\n")) {
const match = /^([\w.]+)=(.*)$/.exec(riga.trim());
if (!match) continue;
const [, chiave, grezzo] = match as unknown as [string, string, string];
const tra = /^(["'])(.*)\1$/.exec(grezzo);
env[chiave] = tra ? tra[2]! : grezzo;
}
return env;
}
export type Voce = { name: string; id: string | null };
/** Tutti i percorsi dei file sotto `prefisso`; Supabase segnala le cartelle con `id: null`. */
export async function elencaFile(
lista: (prefisso: string) => Promise<Voce[]>,
prefisso = "",
): Promise<string[]> {
const file: string[] = [];
for (const voce of await lista(prefisso)) {
const percorso = prefisso ? `${prefisso}/${voce.name}` : voce.name;
if (voce.id === null) file.push(...(await elencaFile(lista, percorso)));
else file.push(percorso);
}
return file;
}
+243 -14
View File
@@ -3,8 +3,36 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "crapp",
"name": "tanstack_start_ts",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@lovable.dev/cloud-auth-js": "^1.1.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
@@ -12,21 +40,30 @@
"@tanstack/react-start": "^1.168.32",
"@tanstack/router-plugin": "^1.168.23",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.575.0",
"motion": "^13.2.0",
"react": "^19.2.0",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.2",
"react-resizable-panels": "^4.6.5",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.3.4",
"vaul": "^1.1.2",
"vite-tsconfig-paths": "^6.0.2",
"zod": "^3.24.2",
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@tanstack/devtools-vite": "^0.8.3",
"@lovable.dev/vite-tanstack-config": "2.8.5",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
@@ -79,12 +116,16 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="],
"@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
@@ -109,6 +150,16 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@hookform/resolvers": ["@hookform/resolvers@5.5.7", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "@sinclair/typebox": ">=0.25.24", "@standard-schema/spec": "^1.0.0", "@typeschema/main": ">=0.13.7", "@vinejs/vine": "^2.0.0 || ^3.0.0", "ajv": "^8.12.0", "ajv-errors": "^3.0.0", "ajv-formats": "^2.1.1", "arktype": "^2.0.0", "ata-validator": "^0.7.0", "class-transformer": ">=0.4.0", "class-validator": ">=0.12.0", "computed-types": "^1.0.0", "effect": "^3.10.3", "fluentvalidation-ts": "^3.0.0", "fp-ts": "^2.7.0", "io-ts": "^2.0.0", "joi": "^17.0.0", "nope-validator": ">=0.12.0", "react-hook-form": "^7.55.0", "superstruct": ">=0.12.0", "typanion": "^3.3.2", "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", "vest": ">=3.0.0", "yup": "^1.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@sinclair/typebox", "@standard-schema/spec", "@typeschema/main", "@vinejs/vine", "ajv", "ajv-errors", "ajv-formats", "arktype", "ata-validator", "class-transformer", "class-validator", "computed-types", "effect", "fluentvalidation-ts", "fp-ts", "io-ts", "joi", "nope-validator", "superstruct", "typanion", "valibot", "vest", "yup", "zod"] }, "sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
@@ -129,6 +180,14 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="],
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.2.1", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-JdADRwpEJA5t0ggXbd96dND23Wsp69Af9lVSV5m7MnMGxJZlPgqAqz7HUKcfJESfI5M5yFZOl9e0x76JMMDOAg=="],
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.3.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.3.5.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-LxCj6JIbYRQ6peMm2aVn/bhHLkwZ5eZ4Cn6gmh7LfjaplqZHlA24nhCowgt6ZLfbckc4d8tSy0He7H8EiFEXag=="],
"@lovable.dev/vite-tanstack-config": ["@lovable.dev/vite-tanstack-config@2.8.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.8.5.tgz", { "dependencies": { "@lovable.dev/vite-plugin-dev-server-bridge": "^1.2.1", "@lovable.dev/vite-plugin-hmr-gate": "^1.3.4", "@tanstack/devtools-vite": "^0.8.1", "lightningcss": "^1.30.0" }, "peerDependencies": { "@tailwindcss/vite": ">=4.0.0", "@tanstack/react-start": ">=1.100.0", "@vitejs/plugin-react": ">=4.0.0", "nitro": ">=3.0.260603-beta", "vite": ">=5.0.0 <9.0.0", "vite-tsconfig-paths": ">=6.0.0" }, "optionalPeers": ["nitro"] }, "sha512-qPNxEXjvRsrbJHKgHxuLWpRW/gu2nIM+62qjTUCbCno5nRPMKyJQNsgjh48qjAkS6T0MRIkY/3BENNXeGsi5hw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
@@ -183,38 +242,112 @@
"@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="],
"@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collapsible": "1.1.20", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA=="],
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="],
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA=="],
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA=="],
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.16", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.18", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.3.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug=="],
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="],
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg=="],
@@ -247,6 +380,8 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@supabase/auth-js": ["@supabase/auth-js@2.111.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/auth-js/-/auth-js-2.111.0.tgz", { "dependencies": { "tslib": "2.8.1" } }, "sha512-hbRLgyQZEX0SDyF4LYXpv94qOIQyFATfpT5SIs2V0SisHnisVRkYgiPQWzv80l4S2mO3qof78rmoH9j5zoFsYQ=="],
"@supabase/functions-js": ["@supabase/functions-js@2.111.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/functions-js/-/functions-js-2.111.0.tgz", { "dependencies": { "tslib": "2.8.1" } }, "sha512-RW/OCsd6MO592zU8ifzP8/f8XzxxIdpb+Up5XaOtE26Fw+3zTp475WX7+GuuktiD1WF8pFUDe6khUPbMp77RCw=="],
@@ -261,6 +396,8 @@
"@supabase/supabase-js": ["@supabase/supabase-js@2.111.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/supabase-js/-/supabase-js-2.111.0.tgz", { "dependencies": { "@supabase/auth-js": "2.111.0", "@supabase/functions-js": "2.111.0", "@supabase/postgrest-js": "2.111.0", "@supabase/realtime-js": "2.111.0", "@supabase/storage-js": "2.111.0" } }, "sha512-9q0/AULthQnWeiDh1vGyjoJZbSY04bu6qHcWit70pqEYn5Kv/dkCPY62Ja1123jEnJbB9Vd2pjY7Kvk/lK3peA=="],
"@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
@@ -353,6 +490,24 @@
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -415,12 +570,16 @@
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -439,10 +598,38 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -451,8 +638,16 @@
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.398", "", {}, "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ=="],
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
"embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
"enhanced-resolve": ["enhanced-resolve@5.24.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ=="],
"env-runner": ["env-runner@0.1.16", "", { "dependencies": { "crossws": "^0.4.8", "exsolve": "^1.1.0", "httpxy": "^0.5.4", "srvx": "^0.11.19" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": ">=0.2.0", "miniflare": "^4.20260515.0", "wrangler": "^4.0.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare", "wrangler"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-2LRJM4P2KLX6J83QZZrMqvgCDt/D5ea7wPcI3yYiy5cG/9rX5QwdwZFx0D7ktWnjdRyZxYjttGGorb5nFqb1CA=="],
@@ -485,12 +680,16 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
"fast-equals": ["fast-equals@5.4.1", "", {}, "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
@@ -507,8 +706,6 @@
"flatted": ["flatted@3.4.3", "", {}, "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ=="],
"framer-motion": ["framer-motion@13.2.0", "", { "dependencies": { "motion-dom": "^13.2.0", "motion-utils": "^13.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
@@ -541,6 +738,10 @@
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
@@ -597,8 +798,12 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="],
@@ -607,12 +812,6 @@
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"motion": ["motion@13.2.0", "", { "dependencies": { "framer-motion": "^13.2.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-4Hrb5vD6HhjFstLUiCmWvtpsw+WTpP4R+QXfSDYZBz7+uxE/LrRg3aV0ReJxHrTRffHhbIE6svEqnotngXvesQ=="],
"motion-dom": ["motion-dom@13.2.0", "", { "dependencies": { "motion-utils": "^13.0.0" } }, "sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ=="],
"motion-utils": ["motion-utils@13.0.0", "", {}, "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
@@ -625,6 +824,8 @@
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"ocache": ["ocache@0.1.5", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w=="],
"ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="],
@@ -659,22 +860,40 @@
"prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-hook-form": ["react-hook-form@7.83.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-resizable-panels": ["react-resizable-panels@4.12.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rolldown": ["rolldown@1.2.0", "", { "dependencies": { "@oxc-project/types": "=0.140.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.0", "@rolldown/binding-darwin-arm64": "1.2.0", "@rolldown/binding-darwin-x64": "1.2.0", "@rolldown/binding-freebsd-x64": "1.2.0", "@rolldown/binding-linux-arm-gnueabihf": "1.2.0", "@rolldown/binding-linux-arm64-gnu": "1.2.0", "@rolldown/binding-linux-arm64-musl": "1.2.0", "@rolldown/binding-linux-ppc64-gnu": "1.2.0", "@rolldown/binding-linux-s390x-gnu": "1.2.0", "@rolldown/binding-linux-x64-gnu": "1.2.0", "@rolldown/binding-linux-x64-musl": "1.2.0", "@rolldown/binding-openharmony-arm64": "1.2.0", "@rolldown/binding-wasm32-wasi": "1.2.0", "@rolldown/binding-win32-arm64-msvc": "1.2.0", "@rolldown/binding-win32-x64-msvc": "1.2.0" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA=="],
@@ -715,6 +934,8 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
@@ -723,6 +944,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@@ -751,6 +974,8 @@
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
@@ -791,6 +1016,10 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/devtools-bundler-core/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@tanstack/devtools-vite/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -807,14 +1036,14 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
"oxc-parser/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"vite/rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="],
+1 -1
View File
@@ -4,4 +4,4 @@ saveTextLockfile = true
minimumReleaseAge = 86400
# Each entry bypasses the 24h guard for one package — confirm with the user
# before adding any.
minimumReleaseAgeExcludes = []
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config", "@lovable.dev/mcp-js", "@lovable.dev/vite-plugin-dev-server-bridge", "@lovable.dev/vite-plugin-hmr-gate", "@lovable.dev/email-js", "@lovable.dev/webhooks-js"]
-181
View File
@@ -1,181 +0,0 @@
# Architettura del progetto
Come è fatta CrAPP: stack, organizzazione del codice, flusso di sviluppo. È il documento di
riferimento tecnico — `CLAUDE.md` non ripete questi contenuti, li richiama.
## Stack
| Livello | Tecnologie |
| ------------- | -------------------------------------------------------------------------------- |
| Frontend | React 19, TypeScript, TanStack Start (SSR), Vite 8, Tailwind CSS 4, motion, vaul |
| Backend | Supabase (PostgreSQL, Auth, Storage) |
| Hosting | Vercel |
| Versionamento | Git, GitHub |
Le dipendenze sono installate con **bun** (`bun.lock`, `bunfig.toml`). `bunfig.toml` impone
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
`minimumReleaseAgeExcludes` richiede conferma esplicita.
## Struttura del progetto
```
src/
components/ componenti condivisi (crapp/, ui/, motion/)
routes/ routing file-based
lib/ logica di dominio, un file per modulo
integrations/ client Supabase e integrazioni esterne
assets/
supabase/ migration SQL
test/ suite di test (unit, integration, end-to-end)
docs/ documentazione ufficiale
```
## Punti fermi
- **Routing**: file-based in `src/routes/`. `src/routeTree.gen.ts` è **generato**, non si
modifica a mano.
- **Configurazione Vite**: `vite.config.ts` usa `@lovable.dev/vite-tanstack-config`, che
include già devtools, tanstackStart, viteReact, tailwind, tsconfig-paths, nitro e l'alias
`@` → `src/`. **Non ri-aggiungere questi plugin**: l'app si rompe.
- **Entry point server**: `src/server.ts` avvolge l'entry di TanStack Start per intercettare
gli errori SSR che h3 trasformerebbe in un 500 JSON silenzioso, e renderizza
`renderErrorPage()`. `src/start.ts` registra i middleware globali (error handler, CSRF sui
server functions, `attachSupabaseAuth`).
- **Supabase**: `src/integrations/supabase/client.ts` (browser/SSR, chiave publishable — file
generato) e `client.server.ts` (`supabaseAdmin`, solo server). `types.ts` è generato dallo
schema; finché non viene rigenerato, le tabelle introdotte da M1/M2 si usano tramite
`client-nuove-tabelle.ts`, con i tipi di riga dichiarati nei moduli di `src/lib/`.
- **Autenticazione**: login Google via Supabase Auth (`src/lib/auth.ts`, DD-011). È l'unica
strada di accesso: `__root.tsx` rimanda a `/benvenuto` chi non ha sessione, e l'identità
del giocatore è lo slot di `giocatori_squadra` collegato all'account. I permessi di
amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`).
## Livello dati
Tutta la logica di dominio sta in `src/lib/`, un file per modulo (`presenze`, `eventi`,
`pagelle`, `mvp-voti`, `palloni`, `cacche`, `badges`, `scout-*`, `infortuni`, …). Il pattern
ricorrente:
- ogni modulo esporta hook TanStack Query (`useX`); i default globali stanno in
`src/router.tsx` (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect`
disattivati, `retry: 1`);
- dopo una mutazione la cache si aggiorna con `setQueryData`, **non** con
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più. Unica
eccezione: `scout-live.ts`, dove il lock può essere stato preso da un altro dispositivo,
quindi quello che abbiamo scritto non è detto sia quello che vale;
- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs
`palloni.ts`, `mediePagelle()` vs `usePagelle()`);
- `src/lib/rosa.ts` è l'aggregatore: compone tutti gli hook e restituisce la rosa completa
con le statistiche derivate, **senza query aggiuntive** rispetto a quelle già in cache. Le
route consumano `useRosa()`, non i singoli moduli.
Nessun accesso al database dai componenti: solo attraverso i moduli in `src/lib/`, così il
backend resta sostituibile in un solo punto (DD-013, [PORTABILITA.md](PORTABILITA.md)).
Vincoli di efficienza cloud — niente polling, cache lunga, `setQueryData` invece di
`invalidateQueries` — in [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md).
Badge e statistiche sono calcolati a runtime dai dati, non persistiti (DD-007). La
gamification deve restare equa tra ruoli (DD-008).
La rosa vive nella tabella `giocatori_squadra`, letta tramite `useRosa()`/`useGiocatoriSquadra()`
(DD-015, DD-016). `src/lib/crapp-data.ts` (`rosaCSI`) resta solo come seed storico e fallback
quando il database non risponde.
## UI
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `Card`, `PageHeader`,
`Section`, `StatTile`; `Avviso.tsx` per gli avvisi colorati in Home; i drawer di dettaglio `BadgeDrawer` e `ObiettivoDrawer`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
schermate, pochi click.
**Caratteri** (DD-049): niente testo sotto i 13 px, tranne la navigazione a pillola e i badge
da 10-11 px. Scala in uso: 13 px note, 15 px corpo, 16 px titoli di card; si usano le misure
fisse `text-[13px]`…`text-[16px]`, non `text-xs` e `text-sm`.
In `src/components/ui/` restano solo le due primitive shadcn davvero usate, `drawer` (vaul) e
`sonner`: le altre 43 non erano importate da nessuna parte (DD-021). Il resto dell'interfaccia
è composto con Tailwind e la primitiva `Card`, che è l'unica definizione di raggio, sfondo e
ombra delle superfici.
L'app è **solo chiara** (DD-022): non esiste un tema scuro e `:root` dichiara
`color-scheme: light`.
Il movimento usa molle interrompibili di `motion` con i preset in `src/lib/molla.ts`
(DD-021): `molla.ui` di default, `molla.slancio` solo dopo un gesto con inerzia,
`molla.foglio` per drawer e cambi di vista. `proietta()` calcola dove finirebbe un elemento
lanciato, così swipe come quello del calendario atterrano dove il gesto stava andando.
`src/lib/motion.ts` conserva solo il rilevamento del movimento ridotto e i coriandoli.
## Trappole note
Cose che si scoprono solo rompendole; ognuna ha la sua decisione o il suo documento.
- **Non invalidare la cache**: dopo una mutazione si usa `setQueryData`, non `invalidateQueries`
([EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md)).
- **Niente database dai componenti**: solo tramite `src/lib/` (DD-013).
- **Tabelle presenti ma non usate**: `giocatori`, `eventi`, `presenze` (DD-014) e `promemoria_push`.
Il modello in uso è `giocatori_squadra`, `eventi_app`, `risposte_presenze`.
- **Route in `src/routes/api/public/`**: usano la service role e saltano la RLS, quindi chiedono
le credenziali (DD-024). Una nuova route che avvisa la squadra deve farlo.
- **`convocati` vuoto significa tutta la rosa**, non nessuno (DD-027).
- **L'allenatore non è un giocatore**: è fuori da `inRosa()`, non vota e non riceve i promemoria
(DD-034, DD-039).
- **Cancellare un evento** pulisce a cascata le tabelle collegate; una tabella nuova legata
agli eventi va aggiunta al trigger (DD-029).
- **Dev e produzione condividono lo stesso Supabase**: le prove distruttive si fanno sullo stack
locale ([OPERATIONS.md](OPERATIONS.md)). Il seed gira solo in locale.
- **Migration**: mai riscrivere una già applicata; un `CHECK` sul tipo di `notifiche_utente` va
esteso con una migration nuova quando nasce un tipo nuovo.
- **Dipendenze**: `bunfig.toml` impone `minimumReleaseAge = 24h`; escluderne un pacchetto richiede
conferma dell'utente.
## Aggiungere un modulo o una funzione
Nell'ordine, perché il progetto è document-first (DD-002):
1. scheda in `docs/modules/<nome>.md` (stato, file principali, obiettivo, dati, permessi) e riga in
[ROADMAP.md](ROADMAP.md); se la scelta è non ovvia, una `DD-XXX` in [decisions/](decisions/README.md);
2. se serve lo schema: tabella documentata in [DATABASE.md](DATABASE.md), **nuova** migration,
permessi di scrittura nella sua tabella e test in `test/integration/permessi.test.ts`;
3. logica pura in `src/lib/<nome>-core.ts` con test in `test/unit/`; hook e accesso ai dati in
`src/lib/<nome>.ts`; componenti in `src/components/crapp/`, riusando quelli esistenti;
4. se tocca le notifiche: il catalogo in [modules/notifiche.md](modules/notifiche.md);
5. voce in [CHANGELOG.md](CHANGELOG.md) sotto `[Non rilasciato]` e checklist «Fine lavoro» di
[AGENTS.md](../AGENTS.md).
## Comandi
```bash
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
npm run format # prettier --write .
npm run test # test unit (veloci, senza rete né database)
npm run test:integration # route server vere
npm run test:e2e # percorsi sull'app servita
npm run test:all # tutto
```
Database di sviluppo in locale (Docker), alternativo al progetto Supabase cloud:
```bash
npx supabase start # avvia lo stack locale e applica tutte le migration
npx supabase stop # spegne i container
npx supabase db reset # ricrea il database da zero: migration + supabase/seed.sql
npx supabase db push # applica le migration al progetto cloud
```
`supabase/seed.sql` popola qualche profilo di prova e gira **solo in locale**. Serve perché
il progetto cloud è uno solo, condiviso tra sviluppo e produzione: lo stack locale è il posto
dove provare le migration distruttive senza toccare i dati veri.
## Branch e flusso di sviluppo
- `main` → produzione, deploy automatico su Vercel. È anche il branch di lavoro corrente.
- `develop` → preview Vercel; oggi indietro rispetto a `main`, non rappresenta lo stato attuale.
- `feature/…`, `fix/…`, `refactor/…` → lavori rischiosi o paralleli.
Su quale branch va un commit lo decide l'utente (DD-019): un assistente AI può consigliare un
branch dedicato, non sceglierlo. Poiché si lavora su `main`, la rete di sicurezza sono i test,
che vanno scritti insieme al codice e devono essere verdi (DD-020, [test/README.md](../test/README.md)).
-531
View File
@@ -1,531 +0,0 @@
# Changelog
Le modifiche rilevanti di CrAPP sono documentate qui, in ordine di rilascio. Il formato
segue [Keep a Changelog](https://keepachangelog.com/it/1.1.0/): ogni versione ha una data
e le voci sono divise per categoria (Aggiunto, Modificato, Sicurezza...). L'elenco
completo delle funzionalità, fatte e previste, sta in [ROADMAP.md](ROADMAP.md); qui si
registra solo _quando_ una voce è stata rilasciata e con quale versione.
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Dalla `1.0.0` si segue il
[versionamento semantico](https://semver.org/lang/it/): `x` cresce con i cambi non
compatibili all'indietro, `y` con le funzionalità nuove, `z` con le sole correzioni. Le
versioni `0.y.z` erano pre-release.
## [Non rilasciato]
## [1.6.2] - 2026-11-11
Layout corretto sugli schermi stretti e statistiche tecniche dai + dello scout in Squadra e
Profilo. Nessuna migration.
### Modificato
- Squadra → Stats: «Punti squadra», «Ace squadra» e «Muri squadra» sommano i + dello scout di
tutte le partite (ace = + di battuta, muri = + di muro, punti = + di attacco, battuta e muro);
le partite del metodo precedente contano con le loro azioni.
- Profilo → Season: al posto di «Turni palloni» compaiono Ace, Attacchi, Muri e Punti (totale)
del giocatore, somma dei + dello scout di tutte le partite (DD-008: solo informativi).
- Layout su schermi stretti (360-402 px, iPhone e Android): niente più pagina tagliata in
larghezza. La tabella per giocatore dello scout sta nello schermo (il nome va a capo su due righe), le
griglie di riquadri non sfondano con nomi ed etichette lunghe, la pagina non scorre in
orizzontale e i campi di testo sono a 16 px, così iOS non zooma al tocco. I nomi non si spezzano
mai a metà parola; nello scout live i riquadri dei giocatori sono a 3 colonne sotto i 400 px.
## [1.6.1] - 2026-11-06
Risultati dello scout leggibili nell'app, classifica cacche completa e pagina partita a sezioni
a scomparsa. Nessuna migration.
### Aggiunto
- Pagina partita, sezione «Scout»: i risultati dello scout si leggono nell'app, non solo nel CSV
admin. Per ogni fondamentale: percentuale di +, barra verde/rossa e conteggi di squadra; selettore
«Totale» e «Set 1»…«Set 5» e tabella per giocatore con + / −. Il pulsante ⓘ apre una guida su come
leggere i dati. Le azioni (punti, ace, muri, errori) restano in un blocco a parte per le partite
scoutate col metodo precedente; il CSV resta per gli admin.
### Modificato
- Sondaggio cacche: la classifica completa è visibile a tutti i giocatori (non più solo il podio),
anche a sondaggio chiuso, con posizioni a pari merito senza salti (1°, 1°, 2°), barra
proporzionale e la propria riga evidenziata.
- Pagina partita: formazioni, badge, sondaggio, pagelle, scout e rosa sono sezioni a scomparsa
(aperti all'inizio solo rosa e presenze e, nella sua finestra, il sondaggio). Le formazioni
indicano la fonte «Livescore CSI»; lo scout mostra il numero di valutazioni. I titoli delle
sezioni a scomparsa hanno la stessa dimensione delle altre e le chiuse non lasciano vuoto sotto.
- Pagelle di fine partita: non compaiono più i convocati che risultano assenti o infortunati a
quella partita (solo filtro di interfaccia).
- Pagina partita: la sezione «Report tecnico» si chiama «Scout».
## [1.6.0] - 2026-11-02
Voto MVP definitivo e segreto, Scout live a valutazioni. Cinque migration, da `m35` a `m39`,
applicate in produzione il 02/11/2026.
### Aggiunto
- Scout live: per ogni convocato si può segnare un − o un + su battuta, ricezione, attacco e muro,
separatamente per ogni set e senza toccare il punteggio ([DD-053](decisions/DD-053.md), `m39`). Il
CSV admin della partita include una tabella con i conteggi per set.
### Modificato
- Scout live: tolti i tasti dei punti, il tabellone del punteggio, l'elenco «Ultime azioni» e il
blocco avversario / in casa / trasferta; resta la scelta del giocatore e la valutazione − / + per set
([DD-053](decisions/DD-053.md)). «Chiudi set» e «Fine partita» funzionano anche senza punti; le
partite di sole valutazioni non compaiono come risultato in Home, Classifica e pagina partita.
- Scout live: il salvataggio è immediato a ogni tocco (prima c'era una pausa di 0,8 secondi) e,
se non riesce, compare un avviso.
- Scout live: la griglia «Selezione giocatore» si ordina per cognome o per numero; nel riquadro di
ogni giocatore compaiono i + del set in corso (ricezione esclusa) al posto dei punti.
- Scout live: dopo «Fine partita» il messaggio dice solo «Partita salvata» (il voto MVP apre 2 ore
dopo l'inizio della partita, non dipende dallo scout).
- Il voto MVP è definitivo ([DD-051](decisions/DD-051.md)): si sceglie un nome, si conferma e non si
può più cambiare. Il database lo impone (solo gli admin possono correggere un voto).
- In caso di parità in testa gli MVP sono più d'uno: ognuno dei pari merito conta un titolo, e in
Home e Classifica compaiono i nomi uniti («Anna e Bruno»). Resta il minimo di 2 voti per eleggere
un MVP.
- Il voto MVP è segreto ([DD-052](decisions/DD-052.md)): nessuno vede chi ha votato chi, solo i
conteggi e il proprio voto.
- La votazione MVP si chiude 24 ore dopo l'apertura (cioè 26 ore dopo l'inizio della partita): le
partite più vecchie non si votano più.
- I test non pretendono più una gara CSI già giocata: a inizio stagione il calendario c'è ma i
risultati no (un test si salta finché non c'è una gara giocata).
### Sicurezza
- Il database impone anche le regole che prima valevano solo nell'app: il voto MVP è possibile solo per
una partita, tra presenti (`presente` o `ritardo`) e dopo 2 ore dall'inizio (`m36`,
[DD-051](decisions/DD-051.md)). Gli admin possono ancora correggere un voto.
- Il nome del votato lo scrive il database dalla rosa, per i voti MVP (`m37`) e per i badge social
(`m38`): un client non può più salvare un nome diverso da quello del giocatore votato. In
produzione non risultavano voti MVP, quindi nessun dato da bonificare.
## [1.5.0] - 2026-10-29
Campionato CSI 2026/27. Nessuna migration.
### Modificato
- Campionato CSI passato alla stagione 2026/27: nuovo `project_id` (933) e nuovo `team_id`
(3878). La Coppa non c'è quest'anno e la sezione resta nascosta; lo storico 2025/26 non viene
più mostrato.
### Dati
- Calendario di produzione aggiornato a mano con le 12 gare di campionato già definite dal CSI
(4/11/2026 – 11/3/2027): titolo «CAMPIONATO», luogo e link Google Maps dell'impianto. I 9
allenamenti nello stesso giorno di una gara sono stati eliminati.
## [1.4.2] - 2026-10-26
Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il
26/10/2026.
### Aggiunto
- Link Google Maps del luogo: in «Gestione eventi» (admin e allenatori) un campo facoltativo
accetta il link copiato da Google Maps; nelle schede di partita, allenamento ed evento compare
«Apri su Maps» solo se il link c'è. Non compare nella card né nelle notifiche. Il link
è validato (solo https di Google Maps) sia nell'app sia da un CHECK sul database.
## [1.4.1] - 2026-10-19
Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati.
Nessuna migration.
### Modificato
- Un evento con dei convocati compare nel Calendario, in Home e nell'elenco eventi solo ai
convocati. Admin e allenatori lo vedono sempre; `convocati` vuoto = visibile a tutti. È un
filtro dell'interfaccia: i dati restano leggibili dal database (RLS di `eventi_app` invariata).
### Corretto
- «Rosa e presenze» di allenamenti, partite ed eventi mostra solo i convocati (prima elencava
sempre tutta la rosa, anche se ne era stato convocato uno solo). Il conteggio «Hanno risposto»,
il pulsante «Sollecita» e il riquadro «La tua risposta» seguono la stessa regola; `convocati`
vuoto resta «tutta la rosa».
- La card dell'evento (Calendario e Home) conta «presenti/convocati» invece di dividere per tutta la
rosa, e i pulsanti di presenza compaiono solo ai convocati: un admin che è anche giocatore ma non
è convocato vede l'evento senza poter rispondere. In «Da confermare» (Home) non finiscono più gli
eventi a cui non sei convocato.
## [1.4.0] - 2026-10-09
Gli admin e gli allenatori vengono avvisati quando una presenza cambia a ridosso di una partita o di un allenamento.
Due migration, `m32` e `m33`, entrambe applicate in produzione il 09/10/2026.
### Aggiunto
- **Notifica «Presenza modificata» (DD-050)** — quando una risposta a una partita o a un allenamento cambia,
compare o viene ritirata a meno di 6 ore dall'inizio, gli admin e gli allenatori ricevono una notifica (centro
notifiche, push, email) con chi, quale evento e lo stato prima e dopo. Migration `m32` (solo admin, applicata in
produzione il 09/10/2026) e `m33` (aggiunge gli allenatori, applicata il 09/10/2026).
## [1.3.1] - 2026-10-06
Il Calendario mostra solo i prossimi compleanni del mese, e le serie di presenze sono due invece di tre: sparisce
«Conferme 24h» con i due badge che ne dipendevano. Nessuna migration da applicare.
### Modificato
- **Calendario: «Prossimi compleanni»** — la sezione in fondo mostra solo i compleanni del mese in corso non
ancora passati, non più quelli del mese selezionato nella griglia; se nel mese non ce ne sono, lo dice
(«Nessun compleanno questo mese»).
### Rimosso
- **Serie «Conferme 24h» e badge collegati** — la serie non compare più nel profilo, in Home né nelle notifiche di
traguardo; restano Allenamenti e Partite. Rimossi anche il badge «Risposta lampo» e il segreto «Mai un forfait», che
si basavano su di essa. Nessuna migrazione: la colonna `risposto_il` resta, inutilizzata.
## [1.3.0] - 2026-10-06
Gli auguri di compleanno arrivano da soli, gli obiettivi di squadra si possono toccare per capire come funzionano
e le presenze si contano solo sugli eventi a cui si è convocati. In tutta l'app i caratteri sono più grandi e gli
avvisi in Home più chiari. La migration `m31_notifica_compleanno` è **da applicare in produzione** (`npx supabase
db push`); il resto non tocca il database.
### Aggiunto
- **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
`m31_notifica_compleanno`, da applicare in produzione.
- **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, cosa è una presenza persa, periodo di validità, fonte dei dati,
un esempio con numeri. Nessuna migration.
### 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. Assente, forse,
infortunato e nessuna risposta restano presenze perse. Nessuna migration.
- **Obiettivo «Presenze collettive»** — sostituisce «250 presenze complessive»: è la percentuale di presenze di tutta la
rosa su allenamenti e partite definiti in stagione (passati e futuri, posti = convocati), con target 90%. 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.
- **Avvisi in Home più leggibili** — certificati e turno palloni usano lo stesso componente (`Avviso`): icona
propria per ogni caso (non solo il colore), titolo e testo più grandi, elenchi di persone in righe con il dettaglio
a destra e il conteggio, freccia dove la card porta ai documenti, comparsa graduale. Il turno palloni ha la pillola
«Oggi»/«In arrivo» e i messaggi in riquadri. Solo aspetto: testi e regole di quando compaiono non cambiano.
Nessuna migration.
- **Caratteri più grandi in tutta l'app** — ogni testo sotto i 16 px sale di un passo (12→13, 14→15, ecc.), nelle
card, negli elenchi, nelle statistiche e nei drawer. Restano uguali le barre a pillola dei menu (navigazione in
basso e sottosezioni) e i badge minuscoli. Solo aspetto, nessuna migration (DD-049).
- **Etichetta «Cacche» nella rosa** — nella scheda del giocatore in Squadra la riga «Cacche/partita» si chiama
«Cacche»; il valore resta la media per partita. Nessuna migration.
### Rimosso
- **Tessera CSI dalla dashboard** — in Profili spariscono il blocco «Tesseramento CSI» (numero e data
tessera), il badge «Tesserato»/«Da tesserare» e il riquadro «Tesserati» della tab Squadra. L'export CSV non
cambia. Le colonne `numero_tessera` e `data_tessera` restano a database, non più usate (DD-048). Nessuna migration.
## [1.2.3] - 2026-10-04
Nella Home si vede anche chi non ha caricato il certificato medico, e le dipendenze sono aggiornate perché Vercel bloccava il deploy. Nessuna migration.
### Modificato
- **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa:
il giocatore vede il proprio avviso, l'admin l'elenco di chi manca, sopra scaduti e in scadenza (DD-046).
### Sicurezza
- **Dipendenze aggiornate** — `@tanstack/react-start` dalla 1.168.34 alla 1.168.60 (con `react-router`, `router-plugin` e `react-query`): Vercel bloccava il deploy per la versione precedente. `npm audit fix` chiude anche le tre vulnerabilità alte di `brace-expansion` e `nanoid`; `npm audit` segnala 0 vulnerabilità.
## [1.2.2] - 2026-10-01
Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il
promemoria degli eventi non c'è più, la foto tessera è stata tolta e l'avviso del certificato medico arriva al
giocatore un mese prima. Le migration `m27`–`m30` sono applicate in produzione dal 01/10/2026; questa versione porta il
codice dell'app che le accompagna, da rilasciare subito: la `m29` ha tolto la colonna `foto_path` che la versione
precedente ancora legge.
### Rimosso
- **Foto tessera del profilo** — il giocatore non la carica più e l'admin non la vede nella scheda; il
completamento del profilo si calcola su dati personali (34%), documento (33%) e certificato (33%). Migration
`m29_senza_foto_tessera` (elimina la colonna `foto_path`); i file già caricati sono stati cancellati dal
bucket `profili-giocatore` (DD-044). Applicata in produzione il 01/10/2026, con la cancellazione dei 4 file già caricati.
- **Promemoria evento a 24 e 3 ore** — non partono più per nessun evento (allenamenti, partite, extra-campo):
niente notifica, push né email. Migration `m28_niente_promemoria_evento` (tolti i due job `pg_cron`;
funzione, tipi e notifiche già generate restano). Applicata in produzione il 01/10/2026 (DD-043).
### Modificato
- **Sollecito presenze: un solo avviso automatico, 24 ore prima** — a chi non ha risposto o ha risposto
«forse»; prima ne partivano tre (24, 12 e 6 ore). Il pulsante «Sollecita» non cambia. Migration
`m30_sollecito_solo_24h`, applicata in produzione il 01/10/2026 (DD-045).
- **Turno palloni: un solo avviso, 3 ore prima** — a chi deve portare i palloni e a chi li deve
prendere; prima ne partivano tre (12, 6 e 3 ore). Migration `m27_palloni_solo_3h`, applicata in
produzione il 01/10/2026 (DD-042).
- **Avviso certificati: giocatore a 30 giorni** — il giocatore vede l'avviso giallo sul proprio
certificato medico da un mese prima della scadenza (prima 7 giorni). Per gli admin non cambia
nulla: 7 giorni prima in giallo, nero dopo la scadenza (DD-041).
## [1.2.1] - 2026-09-30
Turno palloni e sollecito presenze partono anche da soli, a orari fissi prima dell'evento, e i testi di
tutte le notifiche legate agli eventi sono più formali e completi (DD-040). La migration `m26` è applicata in
produzione dal 30/09/2026; questa versione porta il codice dell'app che la accompagna.
### Aggiunto
- **Turno palloni automatico** — 12, 6 e 3 ore prima di allenamenti e partite con un incaricato confermato,
all'incaricato («incarico assegnato») e a chi aveva i palloni all'evento precedente («riconsegna»), se è
un'altra persona. Se l'incaricato cambia, chi era stato avvisato riceve una sola volta «Turno palloni:
incarico revocato». Arriva come push e nel centro notifiche, non per email. Il pulsante «Avvisa chi è di
turno» resta.
- **Sollecito presenze automatico** — 24, 12 e 6 ore prima dell'evento, ai convocati (o a tutta la rosa
attiva se non ce ne sono) che in quel momento non hanno risposto o hanno risposto «forse», mai
all'allenatore; chi risponde smette di riceverlo. Un evento creato tardi riceve solo il sollecito della sua
fascia. Push, notifica in app ed email. Il pulsante «Sollecita» resta.
- **Migration `m26_notifiche_automatiche`** — tipi di notifica nuovi (`turno_palloni_12h`, `_6h`, `_3h`,
`turno_palloni_revocato`, `sollecita_presenze_24h`, `_12h`, `_6h`), funzioni SQL dei job e due job
`pg_cron` ogni 15 minuti (`avvisi-palloni-automatici`, `solleciti-presenze-automatici`), riservati alla
service role. Ogni notifica automatica si genera una sola volta per giocatore, evento e tipo, anche se
viene eliminata (registro `promemoria_eventi_generati`). Le fasce sono contigue: un evento creato tardi
non riceve tutte le notifiche insieme.
- **Test** — nuovo test di integrazione `notifiche-automatiche` (26 prove) sui job veri del database:
finestre, destinatari, doppioni, revoca, code push ed email, permessi, pianificazione, e confronto con i
testi e l'ordine del codice TypeScript. Nuovi test unitari di testi, destinatari, date e a capo delle
notifiche; aggiornati i test di promemoria, code push ed email, worker e route dei pulsanti.
- **Documentazione** — DD-040 in `decisions/`; sezione «Testi delle notifiche» nel catalogo di
`modules/notifiche.md`; tabella delle funzioni SQL e dei job in `DATABASE.md`.
### Modificato
- **Testi delle notifiche** — i promemoria a 24 e 3 ore dicono «domani» se l'evento è domani, «oggi»
altrimenti, con data, ora e luogo. Turno palloni e sollecito hanno righe etichettate (Evento, Data, Ora,
Luogo, Incarico, Azione). Stessi testi per i pulsanti e per i job. I promemoria già generati non cambiano.
- **Sollecito manuale** — il pulsante «Sollecita» ora segue i convocati dell'evento, come l'automatico,
invece di tutti i giocatori attivi; il testo dice la risposta attuale e chi lo ha chiesto. Il turno
palloni manuale non parte più per email.
- **Turno palloni manuale** — senza un incaricato per l'evento non parte più nessun avviso, nemmeno quello
di riconsegna, come già descritto nel catalogo.
- **Ordine degli eventi dei palloni** — data, poi ora, poi id (prima solo la data): «evento precedente» e
«successivo» coincidono con quelli dei job del database.
- **Centro notifiche** — le righe del corpo di una notifica vanno a capo.
- Il sollecito e il turno palloni non sono più descritti come «solo manuali» nel catalogo delle notifiche.
### Corretto
- `PROJECT_STATE.md` dava `m23`–`m25` come non applicate in produzione: lo sono dal 29/09/2026.
## [1.2.0] - 2026-09-29
Le notifiche arrivano su tre canali (push, centro notifiche in-app ed email) e non si ripetono più.
Le migration `m22`–`m25` sono già in produzione dal 29/09/2026 e il worker delle email è in funzione;
questa versione porta il codice dell'app che le usa.
### Aggiunto
- **Notifiche email** — ogni notifica del centro notifiche in-app (messaggi dello staff, promemoria
a 24 e 3 ore, turno palloni, sollecito presenze, sondaggio pre-partita) arriva anche per email
all'indirizzo Gmail con cui il giocatore si è registrato, gratis e senza aprire porte sul
router: un worker Docker (`mailer/`) sull'host di casa legge una coda su Supabase (migration
`m22_notifiche_email`) e invia tramite un account Gmail dedicato, con nuovi tentativi in caso di
errore e un tetto giornaliero sotto il limite di Gmail. In Profilo → Opzioni c'è l'interruttore
«Email», acceso di default, per account (tabella `preferenze_utente`; DD-036).
- **Push dei promemoria** — i promemoria a 24 e 3 ore, che arrivavano solo in-app e per email,
partono ora anche come push a tutti i dispositivi iscritti del giocatore. Nascono nel database,
che non può firmare una push, quindi la manda lo stesso worker con lo stesso modulo dell'app
(migration `m24_push_promemoria_e_sondaggio`, DD-038). Le iscrizioni scadute vengono eliminate e
una push rimasta in coda oltre 3 ore si scarta. Il worker va ricostruito e configurato con le chiavi
VAPID dell'app (vedi [WORKER_EMAIL.md](WORKER_EMAIL.md)); senza, manda solo le email.
- **Sondaggio pre-partita nel centro notifiche e per email** — prima era solo una push. Ora compare
anche tra le notifiche in-app e arriva per email ai giocatori attivi, allenatori esclusi (DD-038).
- **Documentazione e test** — nuovo [WORKER_EMAIL.md](WORKER_EMAIL.md) (installazione, gestione,
diagnosi, prova in locale, spostamento su un altro host); catalogo delle notifiche in
[notifiche.md](modules/notifiche.md), con cosa le fa partire, a chi arrivano e su quali canali;
tabella delle notifiche dell'allenatore in [allenatore.md](modules/allenatore.md); decisioni
DD-036, DD-037, DD-038 e DD-039. Nuovi test di integrazione per la coda delle email, il registro
dei promemoria, la coda push, il sondaggio e i destinatari per ruolo (promemoria, messaggio dello
staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), più i test unitari del
worker.
### Modificato
- **L'allenatore non riceve più i promemoria degli eventi** (24 e 3 ore prima, DD-039): per lui non
c'è nulla da confermare, dato che non risponde alle presenze e non è convocabile, e per una cena di
squadra il promemoria non aveva senso. Riceve ancora i messaggi dello staff. Migration
`m25_promemoria_solo_giocatori`.
- **Messaggio del pulsante «Sondaggio»** — se nessun dispositivo ha le notifiche attive dice che
l'avviso è stato inviato in app e per email, invece di «Nessun dispositivo con le notifiche
attive».
- **`ops/` fuori da git** — il playbook Ansible locale per il backup cifrato non è più tracciato.
### Corretto
- **I promemoria dell'evento non ricompaiono più se li elimini** — il promemoria a 24 e a 3 ore
eliminato dal centro notifiche veniva rigenerato dal job automatico al giro successivo, come non
letto e, con le email, con una nuova mail a ogni giro fino all'inizio dell'evento. Ora il job
ricorda ciò che ha già generato (migration `m23_promemoria_gia_generati`, DD-037). Il difetto
esisteva dal centro notifiche (`m17`) e si è notato con l'arrivo delle email.
## [1.1.1] - 2026-09-28
### Aggiunto
- **Keepalive giornaliero** — un Vercel Cron Job chiama `/api/public/keepalive` una volta
al giorno con una lettura minima, per evitare che il progetto Supabase free tier vada in
pausa dopo 7 giorni senza richieste API.
- **Scheda evento** — gli eventi extra-campo (es. cena di squadra) hanno ora una pagina di
dettaglio propria, come allenamenti e partite, con l'elenco nominativo di chi ha risposto
presente/assente/forse invece del solo conteggio in card. "Infortunato" non compare più
tra le opzioni né nei badge/riepiloghi per questi eventi, dato che non è una risposta
pertinente.
### Modificato
- **Backup completo** — la cifratura (gpg, `tar.gz.gpg`) è ora il comportamento di default
invece che opt-in con `--encrypt`, dato che i dump contengono dati sensibili (hash
password, documenti d'identità); si disattiva con `--no-encrypt`. Aggiunte le istruzioni
di decompressione nel README.
### Corretto
- **Squadra** — la card dell'allenatore nella Rosa è collassata come quella dei giocatori
(nome e ruolo in riga chiusa, data di nascita solo aprendo il dettaglio) invece di
mostrare subito tutto.
- **Squadra** — uno scroll verticale veloce nella tab Stats non fa più scattare per errore
il cambio tab verso Obiettivi.
- **Turno palloni** — gli eventi generici (es. cena di squadra) non entrano più nel turno
palloni: niente più proposta automatica, promemoria push o conteggio per eventi che non
sono allenamenti o partite.
## [1.1.0] - 2026-09-27
### Aggiunto
- **Avviso certificati in scadenza** — in Home, giallo nei 7 giorni prima e nero dopo la
scadenza del certificato medico. Gli admin vedono i nomi di tutta la rosa (non
cliccabile); il giocatore interessato vede solo il proprio e il tap apre
`/profilo?tab=documenti`. Calcolato al volo da `certificato_scadenza`: nessuna migration,
nessuna push (DD-035).
## [1.0.0] - 2026-09-24
Prima versione stabile. Oltre al ruolo allenatore riunisce il lavoro fatto dalla 0.9.2:
centro notifiche in-app, messaggi dell'admin e la nuova Gestione eventi a calendario.
### Aggiunto
- **Ruolo allenatore** — l'admin registra un allenatore da `/admin` (tipo «Allenatore», con
l'email Gmail) e lui si collega al primo accesso come i giocatori. Gestisce gli eventi e
sollecita le presenze; ha un profilo ridotto (dati personali e foto, senza stagione e
badge), compare in Squadra con la dicitura «Allenatore» e non vede badge né cacche.
Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (DD-034).
- **Centro notifiche in-app** — lo storico delle notifiche dentro l'app, indipendente dalla
push: messaggi dell'admin, promemoria automatici 24 ore e 3 ore prima di un evento, turno
palloni e sollecito presenze. Ogni notifica si toglie con uno swipe o con la ×. Migration
`m17_notifiche_utente` (DD-030).
- **Messaggi dell'admin** — dalla tab Notifiche della dashboard l'admin manda un messaggio
libero a tutta la squadra o a un solo giocatore; la tab elenca anche chi non ha le
notifiche attive.
- **Gestione eventi a calendario** — `/eventi` è una griglia mensile: si tocca un giorno per
vederne gli eventi e crearne, modificarne o eliminarne uno. Prima di salvare la modifica
di un evento esistente compare una conferma, come per l'eliminazione.
- **Backup completo** — uno script scarica schema e dati del database e i file dello
storage, per poter ripartire se si perde Supabase o Vercel (vedi `PORTABILITA.md`).
### Modificato
- **Notifiche in-app** — il numero delle non lette è un pallino rosso sull'avatar del
profilo, al posto della campanella in alto a destra. Toccando il pallino si apre l'elenco
(leggi ed elimina), toccando l'avatar si va al profilo come prima.
- **Campionato** — l'header riporta la stagione delle gare CSI (es. «Stagione 2025/26») e lo
storico partite è diviso per stagione.
- **Gestione eventi** — tolta la lista "Eventi in calendario": resta solo il calendario
mensile, che si scorre anche con lo swipe come quello della squadra.
- **Gestione eventi** — nel form il giorno toccato sul calendario è già fissato: resta da
scegliere solo l'ora («Cambia» per spostare l'evento in un altro giorno). Il campo
«Luogo» di un nuovo evento parte vuoto invece che con «Palestra Comunale».
### Corretto
- **Squadra** — la data di nascita inserita dal proprio Profilo compare a tutta la squadra
(e nei compleanni del Calendario) invece di «Invalid Date»; allineati anche i profili
compilati prima della correzione. Migration `m18_nascita_pubblica_giocatori_squadra` e
`m19_backfill_nascita_da_profili_esistenti` (DD-031).
- **Squadra** — la rosa non si ricalcola più a ogni render mentre i voti MVP si caricano.
## [0.9.2] - 2026-09-10
### Aggiunto
- **Dashboard amministratore** — nuova tab "Notifiche" che mostra quanti giocatori hanno
le notifiche push attive e chi sono.
### Modificato
- **Dashboard amministratore** — le sezioni impilate diventano un menu di tab scorrevole a
pillole (come Squadra e Campionato); nell'elenco Profili resta aperta una sola scheda
alla volta.
- **Profilo** — testi dei campi amministrativi semplificati (label email, rimossa la nota
su chi vede quei dati).
### Rimosso
- Le dipendenze e il codice legati all'editor Lovable (login social e reporting errori
verso l'editor): l'app non ci gira più.
## [0.9.1] - 2026-09-10
### Modificato
- **Storico partite** — ogni scheda mostra il logo accanto al nome di entrambe le squadre
(CRAP e avversario), risultato e parziali in ordine casa–ospite (verde/rosso restano
vittoria/sconfitta CRAP) e un chevron a destra per chiarire che la riga apre il dettaglio.
## [0.9.0] - 2026-09-09
Prima versione pre-release: lo sviluppo precedente non era versionato a parte, quindi
questa release riunisce tutto ciò che l'app fa oggi in produzione.
### Aggiunto
- **Gestione squadra** — rosa dei giocatori con ruoli e dati anagrafici di base.
- **Profilo Giocatore** — dati personali e amministrativi, documento d'identità,
certificato medico (caricamento, scadenza, stato, download) e foto tessera in
un'unica schermata, sia lato giocatore sia lato amministratore; lo storico dei
certificati resta un'estensione futura.
- **Gestione tesseramenti CSI** — raccolta dei dati richiesti dal CSI, tracciamento di chi
è già tesserato (numero e data tessera) ed export CSV per il tesseramento.
- **Calendario** — eventi di allenamento e partita, con schermata di dettaglio dedicata.
- **Presenze** — conferma o rifiuto della partecipazione a un evento, visibile a tutta la
squadra al posto di chat e fogli condivisi.
- **Serie di presenze** — tre serie (presenze, conferme, allenamenti) calcolate sui dati
reali della rosa.
- **Scout Live** — un solo referente alla volta registra in tempo reale le azioni di gioco
durante la partita.
- **Badge** — gamification con gradi bronzo/argento/oro, badge segreti e badge social
votati tra compagni.
- **Pagelle** — voto tra compagni (1-10) a fine partita, con media personale e di squadra.
- **Votazione MVP** — elezione del migliore in campo della partita tramite voto tra
compagni, un voto a testa.
- **Obiettivi di squadra** — traguardi collettivi che avanzano con presenze, risposte alle
convocazioni, pagelle e risultati di campionato.
- **Turno palloni** — rotazione condivisa e promemoria di chi porta e riporta i palloni ad
allenamenti e partite.
- **Notifiche push** — promemoria intelligenti su un unico opt-in per dispositivo.
- **Dashboard amministratore** — vista aggregata su tesseramenti, certificati, presenze e
dati della rosa, con download CSV.
- **Collegamento CSI** — classifica di campionato e Coppa, storico partite e dettaglio di
ogni gara (formazioni, storico scontri diretti, probabilità di vittoria calcolata dal
CSI) letti in tempo reale dal portale ufficiale Livescore CSI Bologna, senza inserimento
manuale da parte degli amministratori.
- **Infortuni** — conteggio degli eventi saltati per infortunio, riusando lo stato di
presenza già registrato per le convocazioni.
### Sicurezza
- Autenticazione tramite Google via Supabase Auth, unico metodo di accesso; permessi
differenziati per ruolo (giocatore/amministratore) su tabelle e route.
-170
View File
@@ -1,170 +0,0 @@
# Database CrAPP
Struttura del database Supabase (PostgreSQL) e ruolo di ogni tabella. Lo schema autoritativo
sono le migration in `supabase/migrations/`: **una tabella nuova va documentata qui nella
stessa modifica che la crea**. Le funzionalità future stanno in [ROADMAP.md](ROADMAP.md),
non in questo file.
## Permessi di scrittura
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023) e il ruolo
allenatore di `m21_ruolo_allenatore` (DD-034). La **lettura**
resta aperta a tutti gli autenticati su ogni tabella di questo elenco, con le eccezioni della
sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011).
| Tabella | Chi può scrivere |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli. Su `mvp_voti` (`m35`-`m37`, DD-051, DD-052) il giocatore può solo inserire il voto, per una partita aperta al voto (da 2 a 26 ore dopo l'inizio) e tra presenti (`voto_mvp_consentito()`), e leggere solo il proprio; modifica, cancellazione e lettura degli altri voti sono degli admin |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. |
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`.
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
## Lettura
Regola generale: ogni autenticato legge. Le eccezioni sono tutte dati personali o code interne:
| Tabella / bucket | Chi legge |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `profili_giocatore` | il titolare e gli admin (contiene documento d'identità e certificato medico, DD-016) |
| `bucket profili-giocatore` | privato: il titolare la propria cartella, l'admin tutto via signed URL a scadenza breve |
| `notifiche_utente` | il destinatario, solo le proprie righe (RLS, DD-030) |
| `preferenze_utente` | l'utente, solo la propria riga |
| `notifiche_email_coda`, `notifiche_push_coda`, `promemoria_eventi_generati` | nessuno da client: RLS attiva senza policy, solo service role |
| `giocatori_squadra.nascita` | tutta la rosa attiva: è una colonna pubblica sincronizzata, non una lettura di `profili_giocatore` (DD-031) |
## Anagrafica e utenti
| Tabella | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `giocatori_squadra` | Anagrafica operativa della squadra: source of truth della rosa (DD-015). | ID testuali (`g1`…`gN`). Dettaglio colonne qui sotto. Letta con `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`; `src/lib/crapp-data.ts` resta solo come seed e fallback (`rosaFallback()`). |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento, certificato medico e path dei file; 1:1 con `giocatori_squadra`. | Letta da `src/lib/profili.ts` (DD-016). I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (`admin`, `allenatore`, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano (vedi [OPERATIONS.md](OPERATIONS.md)). `allenatore` non si inserisce a mano: lo gestisce il trigger `sincronizza_ruolo_allenatore` (DD-034). |
Colonne di `giocatori_squadra` che meritano una nota:
| Colonna | Significato |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email` | Chiave del collegamento automatico account↔giocatore al primo accesso (DD-018). Impostabile da `/admin`. |
| `auth_user_id` | Collega lo slot all'account; NULL finché nessuno lo reclama. |
| `numero_tessera`, `data_tessera` | Non più usate dall'interfaccia (DD-048): i valori già inseriti restano, nessuna schermata li mostra o li scrive. Il trigger le include ancora tra i campi bloccati per chi reclama il proprio slot. |
| `nascita` | Pubblica a tutta la rosa (DD-031). Non si scrive mai da qui: il trigger `sincronizza_nascita_pubblica` su `profili_giocatore` la tiene allineata a `data_nascita` (impostata, aggiornata o azzerata). |
| `tipo` | `giocatore` o `allenatore` (DD-034). L'allenatore ha uno slot per collegarsi e per i dati personali ma è fuori dalla rosa di gioco (`inRosa()`), con `numero` NULL (vincolo `giocatori_squadra_numero_giocatori`) e `ruolo` vuoto. Lo scrive solo un admin. |
`giocatori_squadra` è usata da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
## Storage
| Bucket | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `profili-giocatore` | Documento d'identità e certificato medico, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato e destinato a restare tale**: documenti e dati sanitari non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella; l'admin scarica tutto con signed URL a scadenza breve. |
| `avatar-giocatori` | Foto profilo (`<giocatore_id>/avatar.jpg`). | **Pubblico**: foto informali. Qualsiasi autenticato può caricare, sostituire o eliminare un file (nessun controllo per proprietario: la maggior parte dei giocatori non ha ancora `auth_user_id`, DD-018). Letto da `src/lib/avatar-store.ts`. |
## Eventi e presenze
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. `link_maps` (M34) è il link Google Maps facoltativo del luogo, con CHECK che accetta solo URL https di Google Maps. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. |
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
## Scout
| Tabella | Scopo | Note |
| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. |
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione. | Solo per statistiche di squadra, mai classifiche individuali (DD-008). `src/lib/scout-stato.ts`. |
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni, valutazioni). | Letta/scritta da `src/lib/scout-store.ts`. `valutazioni` (M39, DD-053): − / + per fondamentale. |
## Votazioni
Per tutte: un voto per votante e partita (per categoria nei badge social), auto-voto rifiutato,
votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------- | --------------------------------------------------------------------------------- |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`; voto definitivo per i giocatori (`m35`). |
| `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. |
| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`; `votato_nome` dal trigger sulla rosa (`m38`). |
## Turni e notifiche
| Tabella | Scopo | Note |
| ---------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Turni palloni **confermati**. | Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)). |
| `push_subscriptions` | Dispositivi registrati per le notifiche push. | |
| `promemoria_push` | **Non più usata.** | Serviva da coda del testo quando la push partiva vuota (DD-026). Da eliminare con una migration. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto. | Vedi sotto. Letta e segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), solo via RLS. |
| `notifiche_email_coda` | Coda delle email: una riga per notifica, con stato d'invio. | Vedi sotto. Specifica in [notifiche-email.md](modules/notifiche-email.md). |
| `notifiche_push_coda` | Coda delle push dei promemoria: una riga per notifica, con stato. | Vedi sotto. |
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Chiave `auth_user_id`. Oggi una colonna, `email_notifiche` (default `true`). **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore in Profilo → Opzioni (`useEmailNotifiche()`). |
| `promemoria_eventi_generati` | Registro dei promemoria già generati, per giocatore, evento e tipo. | Chiave `(giocatore_id, evento_id, tipo)`, `ON DELETE CASCADE` verso giocatore ed evento (DD-029, DD-037). Eliminare la notifica non fa rigenerare il promemoria. È anche la memoria che permette la revoca (DD-040). |
### `notifiche_utente`
- Sorgenti: messaggio libero dell'admin, promemoria evento automatici, turno palloni, sollecito
presenze e sondaggio pre-partita (`sondaggio_cacche`). Dettaglio e testi in
[modules/notifiche.md](modules/notifiche.md).
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
- Il `CHECK` sul tipo ammette anche `presenza_modificata` (M32, DD-050), `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
`sollecita_presenze` restano i tipi dei pulsanti manuali.
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
automatici (compleanni e `presenza_modificata` compresi) ma non quelli dei pulsanti. `presenza_modificata` ha `evento_id` NULL (come `admin`): la UNIQUE non si applica e ogni modifica resta una riga (M32, DD-050).
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
Stessa struttura: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`; stati
`in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo`
(backoff), `errore` (solo codici SMTP per l'email, HTTP per la push), `inviata_il`, `aggiornata_il`.
- Sono riempite da trigger su `notifiche_utente` (`accoda_notifica_email`, `accoda_push_promemoria`)
per le notifiche create dopo le rispettive migration, senza backfill.
- Le usa solo il worker `mailer/` con la service role, tramite funzioni `SECURITY DEFINER`
riservate a `service_role`: `prendi_notifiche_email(p_max)`, `esito_notifica_email(...)`,
`email_inviate_ultime_24h()`, `prendi_push_promemoria(p_max)`, `esito_push_promemoria(...)`.
I lotti usano `FOR UPDATE SKIP LOCKED` e recuperano le righe ferme in `in_invio` da più di 10 minuti.
- RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`.
- Sono tabelle separate da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga
gli permetterebbe di riportare a `in_coda` una mail già partita (DD-036).
- Il worker salta gli slot senza account o email, o con le email spente; per le push salta chi non
ha dispositivi e i promemoria creati da oltre 3 ore.
### Funzioni SQL e job delle notifiche
Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano funzioni `SECURITY DEFINER`
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
| Funzione (migration) | Cosa fa | Job `pg_cron` |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
| `avvisa_presenza_modificata()` (M32, M33) | Trigger su `risposte_presenze` (insert, update dello stato, delete): se lo stato cambia a 0-6 ore da una partita o un allenamento, scrive una notifica `presenza_modificata` per ogni admin e allenatore attivo (DD-050; la M32 solo admin), tranne il giocatore della presenza. Usa `nome_stato_presenza(stato)` per i testi. | trigger `risposte_presenze_avvisa_modifica` |
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
## Badge
Non esiste una tabella dedicata: i badge vengono **calcolati a runtime** dall'applicazione a
partire dai dati esistenti (DD-007).
-37
View File
@@ -1,37 +0,0 @@
# Efficienza cloud
Regola di progetto: CrAPP gira su un piano cloud minimo (Supabase + Vercel) per ~17 utenti.
Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ottimizzati dopo.
## Regole da rispettare
1. **Niente polling**: mai `refetchInterval` verso il database. Per sincronizzare più schede
aperte si usano `BroadcastChannel` o gli eventi di `storage`.
2. **Cache lunga e passiva**: i default del `QueryClient` stanno in `src/router.tsx`
(`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` disattivati,
`retry: 1`). Non alzare la frequenza di refetch modulo per modulo.
3. **Dopo una mutazione si aggiorna la cache con `setQueryData`**, non con
`invalidateQueries`: invalidare costa una rilettura. Unica eccezione:
`src/lib/scout-live.ts`, dove il lock di sessione può essere stato preso da un altro
dispositivo e la riga scritta non basta a sapere chi ha vinto.
4. **Scout Live**: scrive solo chi sta segnando; gli altri leggono dati già salvati.
5. **Write once, read many**: statistiche, badge e classifiche si calcolano una volta e non
si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già
in cache, senza query aggiuntive (DD-007): `src/lib/rosa.ts` aggrega ciò che è già stato
letto.
6. **Push solo per eventi importanti**: convocazioni, turno
palloni, esito finale.
7. **Niente funzionalità pesanti**: foto, video, chat.
8. **Indici** sui campi usati per filtri e relazioni in ogni nuova migration.
## Obiettivi non ancora attuati
Questi punti sono stati definiti come direzione, ma **non sono implementati**: non descrivono
il comportamento attuale.
- **Dati CSI**: sincronizzazione periodica server-side salvata su una tabella locale, con
l'app che legge solo dal database interno. Oggi la lettura è live dal portale a ogni
richiesta, tramite `/api/public/csi` (vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)).
- **Aggregati persistiti**: uno schema con `statistiche_aggregate` e `classifica_csi` è stato
ipotizzato ma non esiste; nessuna di quelle tabelle è in `supabase/migrations/`. Va valutato
con una decisione dedicata, perché tocca DD-007 (badge e statistiche calcolati a runtime).
-29
View File
@@ -1,29 +0,0 @@
# Glossario
Termini del dominio e del progetto, per chi (persona o assistente AI) non conosce la squadra o
il campionato. Dove serve il dettaglio c'è il rimando al modulo.
| Termine | Significato |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Slot** | Riga di `giocatori_squadra`: la «casella» di una persona della squadra. L'account Google la reclama al primo accesso (`auth_user_id`), collegandosi per email (DD-018). Un admin può liberarla. |
| **Rosa** | I giocatori attivi di gioco (`inRosa()`): esclude gli slot disattivati e l'allenatore (DD-034). La legge `useRosa()`. |
| **Allenatore** | Slot con `tipo = 'allenatore'`: gestisce gli eventi, ma non gioca, non ha numero né riceve i promemoria degli eventi (DD-034, DD-039). Vedi [modules/allenatore.md](modules/allenatore.md). |
| **Admin** | Chi ha il ruolo `admin` in `user_roles`. Gestisce squadra, profili e notifiche; i permessi veri stanno nelle policy RLS ([DATABASE.md](DATABASE.md)). |
| **Evento** | Riga di `eventi_app`: `partita`, `allenamento` o `evento` extra-campo (es. cena). I compleanni compaiono in calendario ma non sono eventi di campo. |
| **Convocati** | Giocatori chiamati a un evento. **`convocati` vuoto = tutta la rosa attiva**: è la convenzione di `convocatiEvento()` e di `evento_permette_voto()`. |
| **Presenza / risposta** | Risposta di un giocatore a un evento (`risposte_presenze`): `presente`, `ritardo`, `assente`, `forse`, `infortunato`. Vedi [modules/presenze.md](modules/presenze.md). |
| **Serie** | Streak di comportamenti positivi consecutivi (allenamenti e partite). Vedi [modules/serie-presenze.md](modules/serie-presenze.md). |
| **Pagelle** | Voti 1-10 anonimi ai compagni dopo una partita; solo i convocati votano, finché `pagelle_chiuse = false` (DD-027). Vedi [modules/pagelle.md](modules/pagelle.md). |
| **MVP** | Voto al migliore in campo a fine partita, uno per votante (DD-027). Vedi [modules/mvp.md](modules/mvp.md). |
| **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). |
| **Cacche** | Sondaggio pre-partita (`cacche_partita`) usato per statistiche e per il badge segreto `s-cacche`. |
| **Palloni** | Turno a rotazione di chi porta e riporta i palloni a partite e allenamenti. Vedi [modules/palloni.md](modules/palloni.md). |
| **Scout / Scout Live** | Rilevazione in tempo reale, durante la partita, delle valutazioni − / + di ogni convocato su battuta, ricezione, attacco e muro, per set (DD-053), con blocco condiviso («Scout occupato da X»). Solo statistiche di squadra, mai classifiche individuali (DD-008). |
| **CSI** | Centro Sportivo Italiano, l'ente del campionato. Due usi: il **tesseramento** dei giocatori (manuale, DD-009) e il **collegamento** con classifica e risultati ufficiali ([modules/collegamento-csi.md](modules/collegamento-csi.md)). |
| **Stagione CSI** | Si ricava dalle date delle gare, non da un'impostazione (DD-032). |
| **Centro notifiche** | Storico in-app (`notifiche_utente`) sull'avatar del profilo, separato dalla push (DD-030). |
| **Tre canali** | Ogni notifica può uscire come in-app, push ed email (DD-038). Email e push dei promemoria partono dal **worker** (`mailer/`). |
| **Worker** | Il container Docker in `mailer/` che invia email e push dei promemoria con la service role ([WORKER_EMAIL.md](WORKER_EMAIL.md)). |
| **Service role** | Chiave Supabase che bypassa la RLS. Solo server e worker, mai il client ([OPERATIONS.md](OPERATIONS.md)). |
| **DD-XXX** | Decisione registrata in [decisions/](decisions/README.md). |
| **M1…M27** | Numero d'ordine delle migration (`m27_palloni_solo_3h`…) in `supabase/migrations/`. |
-106
View File
@@ -1,106 +0,0 @@
# Operatività: ambienti, variabili e procedure
Come si allestisce, si configura e si rilascia CrAPP. Le scelte di fondo sono in
[PORTABILITA.md](PORTABILITA.md) (cosa lega l'app a un fornitore, come spostarla) e in
[ARCHITECTURE.md](ARCHITECTURE.md); il worker delle email ha il suo manuale in
[WORKER_EMAIL.md](WORKER_EMAIL.md).
## Ambienti
| Ambiente | Dove | Note |
| ---------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Produzione | Vercel (`main`) + Supabase proprietario | Project Ref `kfkcldwncxqaixetsjes`. Il vecchio `hetycilxgkdmccelwerq` è deprecato: non usarlo. |
| Preview | Vercel, da `develop` e dai branch | `develop` è fermo indietro rispetto a `main` (DD-019): non rappresenta lo stato attuale. |
| Locale | `npm run dev` (8080) + `npx supabase start` | Database in Docker con migration e seed. Dev e produzione possono condividere lo stesso progetto Supabase: vedi «Attenzione». |
| Worker | Raspberry Pi con Docker (`mailer/`) | Email e push dei promemoria. Vedi [WORKER_EMAIL.md](WORKER_EMAIL.md). |
Lovable Cloud non è più il backend di CrAPP (DD-001).
## Variabili d'ambiente
Nomi usati dal codice; i valori stanno in `.env` / `.env.local` (mai nel repository) e nelle
Environment Variables di Vercel (Preview e Production).
| Variabile | Dove serve | Per cosa |
| ----------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------- |
| `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_KEY` | client (browser) | Connessione pubblica a Supabase |
| `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` | server | Come sopra, lato route |
| `SUPABASE_SERVICE_ROLE_KEY` | server e worker | Bypassa la RLS: solo nelle route `src/routes/api/public/` e nel worker. Mai al client. |
| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | server e worker | Firma delle notifiche push. Le chiavi devono essere le stesse nell'app e nel worker. |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASS` | worker | Invio email via Gmail (password per app) |
| `MAILER_FILE_SALUTE` | worker | File usato dall'healthcheck del container |
| `SUPABASE_AUTH_GOOGLE_CLIENT_ID`, `SUPABASE_AUTH_GOOGLE_SECRET` | Supabase locale | Solo per provare il login Google sullo stack locale |
Se aggiungi una variabile, aggiungila anche qui.
## Migration
Ordine obbligatorio (vedi anche [AGENTS.md](../AGENTS.md), sezione Database):
1. si progetta e si documenta in [DATABASE.md](DATABASE.md);
2. si crea una **nuova** migration in `supabase/migrations/` (mai riscrivere quelle applicate);
3. si prova in locale con `npx supabase db reset` e `npm run test:all`;
4. solo dopo `npx supabase db push` verso il cloud;
5. si verifica con `npx supabase migration list` che locale e remoto coincidano.
## Rilascio
`main` è la produzione: ogni commit deve lasciare l'app funzionante. Il deploy è quello di Vercel
sul push di `main`; un cron giornaliero (`/api/public/keepalive`, `vercel.json`) evita la pausa
del progetto. Prima di dire «finito» vale la checklist «Fine lavoro» di [AGENTS.md](../AGENTS.md).
Per tornare indietro: promuovere il deployment precedente da Vercel; le migration, essendo
additive, non si annullano ma si correggono con una migration nuova.
## Allestire un ambiente: autenticazione e primo admin
In produzione su `main`. **Il login è l'unica via d'accesso** (31/08/2026): la selezione
libera del giocatore non esiste più, senza sessione Google si resta su `/benvenuto`, e i
permessi di amministrazione arrivano solo da `user_roles`.
**Attenzione all'ordine:** finché il provider Google è spento in Supabase, «Accedi con
Google» risponde
```
{"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"}
```
e **nessuno entra nell'app**. Vale ancora per chi allestisce un ambiente nuovo (per esempio
lo stack Supabase locale): il passo 1 qui sotto va fatto per primo.
Passaggi in ordine, nessuno dei quali è reversibile a metà. **Stato al 04/09/2026: fatti i
passaggi 1, 2, 3 e 5 (M4 applicata); il passaggio 4 è un processo continuo (7 dei 16 giocatori
attivi hanno già fatto il primo accesso).**
1. **Provider Google in Supabase** — Google Cloud Console: consent screen _External_ (scope
`email` e `profile`, non sensibili: nessuna verifica richiesta, e la modalità _Testing_
regge fino a 100 utenti, più che sufficiente per la squadra), credenziale
_Web application_ con redirect URI
`https://kfkcldwncxqaixetsjes.supabase.co/auth/v1/callback`. Client ID e
secret in _Authentication → Providers → Google_. In _URL Configuration_: Site URL di
produzione, più `localhost:8080` e il wildcard delle preview Vercel tra i Redirect URLs.
Per provare sullo stack locale invece che sul cloud servono anche `enabled = true` in
`[auth.external.google]` di `supabase/config.toml`, le due variabili
`SUPABASE_AUTH_GOOGLE_*` in `.env` e una credenziale con redirect URI
`http://127.0.0.1:54321/auth/v1/callback`.
2. **Migration M2** (`supabase db push`). È un `CREATE` puro: si può applicare in
produzione senza toccare il comportamento attuale.
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';`
4. **Collegamento degli account**: ciascuno accede con Google e viene collegato in
automatico al proprio giocatore per email (DD-018) — nessuna scelta manuale. Finché
l'email di un giocatore non è impostata, il suo accesso mostra un errore; da `/admin` si
imposta l'email di un giocatore (nuovo o esistente) senza bisogno di una migration. Da
settembre 2026 tutti i giocatori attivi hanno l'email registrata, ma il collegamento vero
e proprio (`auth_user_id`) avviene solo al primo login di ciascuno, quindi resta un
processo continuo che si ripete a ogni nuovo giocatore aggiunto a stagione in corso. Uno
slot già collegato può essere liberato solo da un admin.
5. **Solo a squadra collegata**: migration `m4_solo_autenticati`, che toglie al ruolo `anon`
l'accesso alle tabelle v1.0. Da lì in poi i dati sono raggiungibili solo con una sessione;
le route in `src/routes/api/public/` usano la service role e continuano a funzionare.
**Applicata in produzione il 03/09/2026** — non è più necessario aspettare che l'intera
rosa abbia già fatto login: il login era già l'unica via d'accesso lato app, quindi i
giocatori non ancora collegati non erano comunque impattati; M4 chiudeva solo un residuo
di accesso diretto al database bypassando l'app.
Attenzione: dev e produzione condividono lo stesso progetto Supabase. Un account di prova che
collega uno slot lo occupa anche in produzione, e va liberato da un admin.
+10 -19
View File
@@ -5,16 +5,16 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
## Stato attuale
| Componente | Portabile? | Note |
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| Worker email (`mailer/`) | Sì | Container su qualsiasi host con Docker; parla solo con Supabase (RPC), con un server SMTP a scelta e con i servizi push dei browser ([WORKER_EMAIL.md](WORKER_EMAIL.md)). |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
| Componente | Portabile? | Note |
|---|---|---|
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
## Regole da rispettare nelle prossime modifiche
@@ -28,15 +28,6 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
5. Job pianificati richiamabili via semplice HTTP POST (es. `/api/public/promemoria-palloni`),
così funzionano con cron di sistema, pg_cron o qualsiasi scheduler.
## Backup e disaster recovery
Lo script [`backups/backup.sh`](../backups/backup.sh) scarica su un host locale schema e
dati del database (`public` e `auth`), utenti Supabase Auth e file dei bucket storage, in
modo da poter ripartire in fretta se il progetto Supabase (o Vercel) venisse perso.
Uso, contenuto del backup e procedura di ripristino sono descritti in
[`backups/README.md`](../backups/README.md). Non ancora automatizzato: l'export delle
variabili d'ambiente Vercel (richiede login interattivo).
## Migrazione su server proprio (sintesi)
1. `bun run build` (o `npm run build`) → avvio del server Node generato.
-49
View File
@@ -1,49 +0,0 @@
# Documentazione CrAPP
Indice della documentazione ufficiale del progetto. Ogni file risponde a una domanda
precisa: se l'informazione che cerchi non è nel file indicato, probabilmente non esiste
ancora e va **prima documentata** (vedi [DD-002](decisions/DD-002.md)).
## Dove sta cosa
| Documento | Risponde a |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
| [decisions/](decisions/README.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio (backup/disaster recovery incluso) |
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
| [GLOSSARIO.md](GLOSSARIO.md) | Cosa significano i termini del dominio (slot, rosa, convocati, CSI, pagelle…) |
| [OPERATIONS.md](OPERATIONS.md) | Ambienti, variabili d'ambiente, migration, rilascio e allestimento di un ambiente nuovo (login Google, primo admin) |
| [WORKER_EMAIL.md](WORKER_EMAIL.md) | Come si installa, si gestisce e si diagnostica il worker che invia le notifiche per email |
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
| [archive/](archive/README.md) | Materiale storico non più in uso: non è fonte di verità |
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
Le regole vincolanti per gli assistenti AI stanno in [AGENTS.md](../AGENTS.md);
lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md) (una pagina: a che punto
siamo, non la cronologia).
## Ordine di lettura
Prima di modificare il codice, nell'ordine: questo indice → `ROADMAP.md` → `ARCHITECTURE.md`
→ `DATABASE.md` → il documento del modulo interessato in `modules/` → solo le decisioni
(`decisions/DD-XXX.md`) che il modulo o l'indice richiamano, non tutte.
## Regole di manutenzione
Ogni informazione ha **una sola casa**, per evitare che le copie divergano:
- l'elenco delle funzionalità (fatte e previste) sta solo in `ROADMAP.md`;
- `CHANGELOG.md` registra _quando_ qualcosa è stato rilasciato, non ripete l'elenco: segue
il formato Keep a Changelog, con il lavoro non ancora rilasciato sotto `[Non rilasciato]`
e le voci divise per categoria (Aggiunto, Modificato, Sicurezza…);
- il lavoro in corso sta solo in `PROJECT_STATE.md`, che rimanda alla roadmap per il resto;
- lo schema del database sta solo in `DATABASE.md`, allineato alle migration in
`supabase/migrations/`: una tabella nuova si documenta nella stessa modifica che la crea;
- le motivazioni stanno solo in `decisions/`, un file `DD-XXX.md` per decisione; per
aggiungerne una si copia [\_template-dd.md](_template-dd.md) e si aggiorna l'indice.
Convenzioni di scrittura: un solo titolo `#` per file (le sezioni interne partono da `##`),
niente `---` come riempitivo tra i paragrafi, tabelle al posto degli elenchi ripetitivi.
-70
View File
@@ -1,70 +0,0 @@
# Roadmap
Elenco unico delle funzionalità di CrAPP, fatte e previste. È la fonte di riferimento per
il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata e con quale versione,
[PROJECT_STATE.md](../PROJECT_STATE.md) cosa si sta facendo adesso.
## Fatto
Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Gestione squadra
- [x] Calendario
- [x] Presenze
- [x] Serie di presenze
- [x] Scout Live
- [x] Badge
- [x] Badge social
- [x] Pagelle
- [x] Votazione MVP
- [x] Obiettivi di squadra
- [x] Turno palloni
- [x] Infortuni — conteggio eventi saltati, in forma minima
- [x] Notifiche Push (promemoria intelligenti)
- [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
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
(caricamento, scadenza, stato, download); la foto tessera è stata tolta (DD-044); lo storico dei certificati
resta un'estensione futura
- [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data
di tessera è stato tolto dalla dashboard (DD-048)
- [x] Collegamento CSI (stagione 2026/27)
- [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato
- [x] Calendario ufficiale — le gare future del feed CSI compaiono già nella pagina Campionato:
a inizio stagione il calendario c'è prima dei risultati
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
calcolata dal CSI
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
stagione e badge ([specifica](modules/allenatore.md), DD-034)
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima, nero dopo la
scadenza e rosso se il certificato manca (DD-046): agli admin con i nomi dei giocatori, al giocatore solo per il proprio
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035, DD-046)
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
([specifica](modules/notifiche-email.md), DD-036)
- [x] Notifiche automatiche — turno palloni 3 ore prima, sollecito
presenze 24 ore prima (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano; il promemoria a 24 e 3 ore è stato tolto, DD-043) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
- [x] Notifica di compleanno — auguri al festeggiato e avviso a tutti gli altri, ogni giorno dalle 8:00, su push,
centro notifiche ed email ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-047)
- [x] Avviso «Presenza modificata» — una risposta che cambia a meno di 6 ore da una partita o un allenamento avvisa
admin e allenatori ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-050)
## Prossimo
- [ ] Database esercizi
- [ ] AI Allenamenti
- [ ] Archivio allenamenti
## Idee future
- [ ] Gestione quote
- [ ] Calendario Google
- [ ] Backup automatici
- [ ] Analisi statistiche avanzate
- [ ] Widget meteo
- [ ] Analisi Scout con AI
-246
View File
@@ -1,246 +0,0 @@
# Worker email: installazione e gestione
Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche
per email e le push delle notifiche che nascono nel database: turno palloni a 3 ore con revoca, sollecito presenze a 24 ore (DD-040). Cosa fa il canale email sta in
[modules/notifiche-email.md](modules/notifiche-email.md), la push dei promemoria in
[modules/notifiche.md](modules/notifiche.md#push-dei-promemoria-m24); perché è fatto così in
[DD-036](decisions/DD-036.md)
e [DD-038](decisions/DD-038.md).
Il nome del documento resta quello delle email, che sono il canale principale.
**Stato in produzione:** migration `m22` applicata e worker delle email in funzione dal 29/09/2026,
su un Raspberry Pi con Docker. La push dei promemoria (`m24`) richiede le chiavi VAPID nel `.env` del
worker e la ricostruzione del container.
## Cosa serve
| Requisito | Note |
| ---------------------- | -------------------------------------------------------------------------------------------------- |
| Un host con Docker | Sempre acceso e con la porta 587 in uscita libera. Nessuna porta in entrata, nessun IP pubblico. |
| Account Gmail dedicato | Con verifica in due passaggi e una password per app (vedi sotto). |
| Chiave del progetto | La service role di Supabase, oggi nel `.env` della root del repo come `SUPABASE_SERVICE_ROLE_KEY`. |
| Indirizzo dell'app | L'https pubblico a cui puntano i link nelle mail (`APP_URL`). |
| Chiavi VAPID | Le stesse `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` dell'app su Vercel: per la push dei promemoria. |
Il container usa l'immagine `oven/bun:1-alpine`, disponibile anche per ARM (Raspberry Pi). Si
costruisce dalla **radice del repository** (`docker-compose.yml` imposta `context: ..`), perché include
anche `src/lib/webpush.server.ts`, lo stesso modulo push dell'app: `docker compose up -d --build`
va comunque lanciato dentro `mailer/`. `mailer/Dockerfile.dockerignore` ammette nel contesto solo i
file usati, quindi nemmeno il `.env` finisce nell'immagine.
## Account Gmail e password per app
Il worker non usa la password dell'account: Google la rifiuterebbe. Serve una **password per app**.
1. Meglio un account **dedicato** e non quello personale: i messaggi partono da quell'indirizzo.
2. Attiva la **verifica in due passaggi** (myaccount.google.com → Sicurezza e accesso).
3. Apri direttamente **https://myaccount.google.com/apppasswords**: la voce non compare nei menu.
4. Dai un nome (per esempio «CrAPP mailer») e crea. Google mostra **una volta sola** una password di
16 caratteri, a gruppi di quattro: copiala.
5. Nel `.env` va scritta **senza spazi** (16 caratteri attaccati): con gli spazi il file, letto come
script di shell, si rompe. Non va mai incollata in chat né nel repository.
Si può revocare in ogni momento dalla stessa pagina, senza cambiare la password dell'account.
**Se la pagina dice «L'impostazione che stai cercando non è disponibile per il tuo account»:**
| Causa | Cosa fare |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Verifica in due passaggi non attiva, o solo passkey come secondo passaggio | Attiva la verifica e aggiungi anche un'**app di autenticazione** (myaccount.google.com/signinoptions/twosv). |
| Protezione avanzata attiva | Disattivala da myaccount.google.com/advanced-protection. |
| Account Workspace con l'impostazione disattivata dall'amministratore | Usa un account `@gmail.com` normale. |
Se nulla funziona, l'alternativa è un altro account Gmail creato da zero, oppure un relay con un
dominio proprio (vedi DD-036).
## Installazione
1. **Migration in produzione.** `npx supabase db push` applica `m22`. È additiva: non modifica nulla
di esistente e non invia mail finché il worker non parte. Dal momento in cui è applicata, le
nuove notifiche si accodano: **avvia il worker subito**, nella stessa sessione. Le notifiche
accodate e non ancora inviate partirebbero tutte insieme al primo avvio, anche se ormai vecchie
(il 29/09/2026 sono partiti così due messaggi di prova dell'admin).
2. **Configura il worker.** In `mailer/`: `cp .env.example .env`, `chmod 600 .env` e compila:
| Variabile | Default | Contenuto |
| --------------------------------------- | ------------------------------ | -------------------------------------------------------------------- |
| `SUPABASE_URL` | — | URL del progetto |
| `SUPABASE_SERVICE_ROLE_KEY` | — | chiave service role: salta la RLS, va trattata come una password |
| `SMTP_USER`, `SMTP_PASS` | — | account Gmail dedicato e password per app (vanno insieme) |
| `APP_URL` | — | base dei link nelle mail, in https e senza slash finale |
| `MAIL_FROM`, `MAIL_FROM_NAME` | `SMTP_USER`, `CrAPP` | indirizzo e nome del mittente |
| `POLL_SECONDI` | 30 | intervallo di polling |
| `MAIL_LIMITE_GIORNO` | 400 | tetto delle ultime 24 ore, sotto i circa 500 di Gmail |
| `MAIL_LOTTO` | 10 | mail prese a ogni giro |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | `smtp.gmail.com`, 587, `false` | server SMTP (`true` solo per la porta 465) |
| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | — | chiavi per la push dei promemoria (vanno insieme; senza, solo email) |
| `VAPID_SUBJECT` | `mailto:crapp@crapvolley.it` | contatto nell'intestazione VAPID, come nell'app |
Le chiavi VAPID devono essere **le stesse dell'app**: le iscrizioni push dei dispositivi sono
legate alla chiave pubblica con cui sono nate, e con chiavi diverse i servizi push rifiutano l'invio
(403). Se mancano il worker parte lo stesso e nel log compare «push dei promemoria: DISATTIVATA».
`SMTP_USER` e `SMTP_PASS` vanno impostati insieme o nessuno dei due (senza credenziali solo per un
server di prova locale). Un numero non valido o una variabile obbligatoria mancante fa uscire il
worker con l'elenco degli errori. Il `.env` è ignorato da git.
**`APP_URL`:** usa un indirizzo stabile, cioè il dominio di produzione o l'alias del branch
(`…-git-<branch>-….vercel.app`). L'indirizzo di un singolo deploy (`…-<codice>-….vercel.app`)
cambia a ogni pubblicazione e mostra il codice di quel momento. Se il progetto Vercel ha la
Deployment Protection attiva, chi apre il link di un'anteprima vede la richiesta di accesso a
Vercel.
3. **Avvia:** `docker compose up -d --build` dentro `mailer/`. Il container ha
`restart: unless-stopped`, quindi riparte da solo dopo un riavvio dell'host.
4. **Verifica:** `docker compose logs` deve mostrare «SMTP raggiungibile e credenziali accettate» e
«push dei promemoria: attiva»;
dopo circa 30 secondi `docker ps` deve mostrare `healthy`. Poi manda un messaggio a un giocatore
dalla dashboard admin e controlla che la riga in coda passi a `inviata` e la mail arrivi.
## Gestione
| Cosa | Comando (dentro `mailer/`) |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Vedere il log in diretta | `docker compose logs -f` |
| Ultime righe del log | `docker compose logs --tail 50` |
| Stato e salute | `docker ps` (colonna STATUS: `healthy` / `unhealthy`) |
| Dopo aver cambiato `.env` | `docker compose up -d --force-recreate` (il `.env` è riletto a ogni avvio; non serve ricostruire l'immagine) |
| Dopo aver cambiato il codice | `git pull`, poi `docker compose up -d --build` |
| Fermarlo | `docker compose down` (le mail restano in coda e partono alla ripartenza) |
| Spegnere subito l'accodamento | `DROP TRIGGER notifiche_utente_accoda_email ON public.notifiche_utente;` da SQL: le notifiche tornano come prima di M22. Per riattivarlo si ricrea il trigger come in `m22`. |
Il log riporta solo id notifica, tipo ed esito (`inviata`, `da riprovare tra N min`, `fallita`,
`ciclo fermato`), mai indirizzi né testi.
## Vedere cosa è partito
- **Posta inviata dell'account Gmail:** le mail complete, come le ha ricevute il destinatario. È il
riscontro più sicuro.
- **SQL Editor di Supabase**, per stato e contenuto:
```sql
select c.stato, c.inviata_il, c.tentativi, c.errore,
g.nome, g.cognome, n.tipo, n.titolo, n.corpo
from notifiche_email_coda c
join notifiche_utente n on n.id = c.notifica_id
join giocatori_squadra g on g.id = n.giocatore_id
order by n.creato_il desc
limit 50;
```
Per un riepilogo: `select stato, count(*) from notifiche_email_coda group by stato;`. Lo stesso per le push dei
promemoria, con la tabella `notifiche_push_coda` (colonne `stato`, `tentativi`, `errore`, `inviata_il`).
Il testo del messaggio sta in `titolo` (oggetto) e `corpo`.
## Spostare il worker su un altro host
Il container non conserva nulla: la coda, gli esiti e il conteggio delle ultime 24 ore stanno in
Supabase. Cambiare host non fa perdere né duplicare mail.
1. **Sul nuovo host** servono Docker con il plugin Compose e la porta 587 in uscita libera.
L'immagine `oven/bun` esiste sia per ARM sia per x86, quindi l'architettura può cambiare.
2. **Porta il codice:** `git clone` del repository (o `git pull` se c'è già) e usa la cartella
`mailer/`. Se la copi a mano, salta `node_modules`: l'immagine si costruisce da sola.
3. **Copia il `.env`** con un canale sicuro (per esempio `scp`), poi `chmod 600 .env`. Non passa da
chat né da git: contiene la service role e la password per app.
4. **Spegni il vecchio:** `docker compose down` sul vecchio host.
5. **Avvia il nuovo:** `docker compose up -d --build` dentro `mailer/`. Devono comparire «SMTP
raggiungibile e credenziali accettate» nel log e, dopo circa 30 secondi, `healthy` in `docker ps`.
Non serve riconfigurare nulla: `APP_URL`, chiave e password restano quelle del `.env`. Se per un
po' girano entrambi i worker non ci sono mail doppie, perché `prendi_notifiche_email` usa
`FOR UPDATE SKIP LOCKED`; conviene comunque spegnere il vecchio subito, per non confondere i log.
Se il passaggio richiede tempo, le notifiche restano in coda e partono tutte insieme all'avvio del
nuovo worker.
Al primo accesso da un IP nuovo Gmail può chiedere una conferma di sicurezza sull'account. La
password per app di solito continua a funzionare; se nel log del nuovo host compare `EAUTH`,
controlla la posta dell'account mittente e conferma l'accesso.
## Diagnosi
| Sintomo | Causa probabile | Cosa fare |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Il log dice `SMTP non verificato (EAUTH)` | Password per app sbagliata, con spazi, o revocata | Controlla la lunghezza (`awk -F= '/^SMTP_PASS=/{print length($2)}' .env` deve dare 16), rigenera se serve. |
| Container `unhealthy` | Credenziali rifiutate, oppure Supabase non raggiungibile | `docker compose logs --tail 50`. |
| Righe che restano `in_coda` | Worker fermo, oppure tetto giornaliero raggiunto | `docker ps`; il tetto si libera da solo, è una finestra mobile di 24 ore. |
| Righe `saltata` | Slot senza account collegato, senza email, o email spente | Normale: nessun destinatario utile. Verifica `giocatori_squadra.email` e `auth_user_id`. |
| Righe `fallita` | Errore permanente del server di posta | Leggi `errore`: `550` = indirizzo inesistente o rifiutato. |
| La mail arriva ma in spam | Mittente nuovo, nessuna reputazione | «Non è spam» e aggiungere il mittente ai contatti; vedi i limiti noti del modulo. |
| Il pulsante «Apri CrAPP» porta a una pagina vecchia o di accesso | `APP_URL` è l'indirizzo di un singolo deploy o di un'anteprima protetta | Usa un alias stabile e ricrea il container. |
| L'interruttore «Email» non compare in Profilo | L'app pubblicata non contiene ancora il codice del modulo | Pubblica una versione che lo include. |
| Il log dice «push dei promemoria: DISATTIVATA» | Mancano `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` nel `.env` | Aggiungile (le stesse dell'app) e ricrea il container. |
| Push dei promemoria in `fallita` con errore `403` | Chiavi VAPID diverse da quelle dell'app, o payload rifiutato | Controlla le chiavi; la query sotto mostra l'errore. |
| Push dei promemoria `saltata` | Il giocatore non ha dispositivi iscritti, o il promemoria aveva oltre 3 ore | Normale se nessuno ha attivato le notifiche o il worker è stato fermo a lungo. |
## Prova in locale, senza toccare la produzione
`npx supabase start` avvia lo stack locale (la migration `m22` si applica con `npx supabase db reset`),
con Mailpit su http://127.0.0.1:54324: le mail non escono dalla macchina. Il worker si può
lanciare senza Docker con `cd mailer && set -a && . ./.env && set +a && bun mailer.mjs`.
1. **`mailer/.env` per la prova.** `SUPABASE_URL` e `SUPABASE_SERVICE_ROLE_KEY` si leggono con
`npx supabase status -o env` (`API_URL`, `SERVICE_ROLE_KEY`). Per provare **con Gmail vero** si
usano le credenziali reali e il worker gira anche senza Docker; il database resta quello locale.
Per provare **con Mailpit** bisogna far girare il worker in un container (vedi in fondo): la
porta SMTP di Mailpit non è esposta sull'host, solo la sua interfaccia web. In quel caso si
omettono `SMTP_USER` e `SMTP_PASS` e si imposta `MAIL_FROM`. `POLL_SECONDI=5` accorcia l'attesa.
2. **Un giocatore con un account collegato.** Le mail vanno solo agli slot con `auth_user_id` e con
`email`. In locale:
```bash
. <(npx supabase status -o env | grep -E '^(API_URL|SERVICE_ROLE_KEY)=')
UID_=$(curl -s -X POST "$API_URL/auth/v1/admin/users" \
-H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H 'content-type: application/json' \
-d "{\"email\":\"prova-$(date +%s)@example.test\",\"password\":\"prova-12345\",\"email_confirm\":true}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
docker exec supabase_db_<project_id> psql -U postgres -c \
"set session_replication_role = replica; update giocatori_squadra set auth_user_id='$UID_' where id='<slot>';"
```
`session_replication_role = replica` serve perché il trigger di `giocatori_squadra` rifiuta la
service key. L'email dello slot deve essere quella che si vuole ricevere; è **unica** nella
tabella (`giocatori_squadra_email_unica`): se l'indirizzo compare già in un altro slot del seed,
si usa quello slot invece di impostarlo su un secondo.
3. **Una notifica di prova:**
```bash
docker exec supabase_db_<project_id> psql -U postgres -c \
"insert into notifiche_utente (giocatore_id, tipo, titolo, corpo) values ('<slot>','admin','Prova CrAPP','Messaggio di prova');"
```
Entro un ciclo di polling il log mostra `inviata …` e la mail compare in Mailpit (o nella casella).
4. **Fine prova:** `npx supabase db reset` riporta il database locale pulito.
Per provare anche l'immagine Docker, il container si collega alla rete di Supabase
(`--network supabase_network_<project_id>`) e usa `http://supabase_kong_<project_id>:8000` come
`SUPABASE_URL` e `supabase_inbucket_<project_id>` sulla porta 1025 come SMTP.
Per provare la **push dei promemoria** senza un telefono: si genera una coppia di chiavi VAPID di
prova, si avvia un piccolo server HTTP locale che finge di essere il servizio push (risponde 201 su un
percorso e 410 su un altro), si inseriscono in `push_subscriptions` due iscrizioni con chiavi ECDH
valide che puntano a quel server e si crea un promemoria in `notifiche_utente`. Il worker lo cifra, lo
spedisce con `Authorization: vapid …`, `Content-Encoding: aes128gcm`, `Urgency: high` e `TTL`, segna
la riga `inviata` ed elimina l'iscrizione che ha risposto 410. Il 29/09/2026 la prova è stata fatta
così.
L'app in sviluppo (`npm run dev`) richiede un Node più recente del 18: su un host con Node 18 Vite
non parte, e con lui i test di integrazione che avviano il server. Si può usare bun al posto di Node:
`bun --bun x vite dev --port 8080` (con `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` e
`SUPABASE_SERVICE_ROLE_KEY` dello stack locale) e poi `BASE_URL=http://localhost:8080` davanti ai
test. I test del database e del worker non ne hanno bisogno.
## Sicurezza
- Il `.env` contiene la service role e la password per app: permessi `600`, mai in git, mai in chat.
- La service role non passa da Vercel: sta solo sull'host del worker. Anche la chiave VAPID
**privata** sta sull'host, oltre che su Vercel: stessa attenzione, mai in git né in chat.
- Il log delle push riporta solo id notifica e numeri: mai gli endpoint, che identificano il
dispositivo del giocatore.
- Se la password per app o la service role sono state esposte, si revocano/ruotano subito (Google e
dashboard Supabase) e si aggiorna il `.env` con `--force-recreate`.
-21
View File
@@ -1,21 +0,0 @@
### DD-XXX — [Titolo breve della decisione]
**Data:**
**Stato:** Accettata · In valutazione · Sostituita · Obsoleta
**Contesto**
[Quale problema stavamo risolvendo?]
**Decisione**
[Cosa abbiamo scelto?]
**Alternative scartate**
- [Alternativa 1] → [perché no]
- [Alternativa 2] → [perché no]
**Conseguenze**
[Cosa cambia per utenti, admin e team di sviluppo]
**Riesame**
[Quando o in quali condizioni rivedere la decisione]
-8
View File
@@ -1,8 +0,0 @@
# Archivio
Materiale **storico, non più in uso**: non è fonte di verità e non va letto per capire lo stato
attuale. Lo schema vigente sono le migration in `supabase/migrations/`.
| Cartella | Contenuto |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `migrations/` | Le due prime migration del Profilo Giocatore (m2, m3), sostituite da `m2_profili_giocatore` e da `correzione_schema_profili_giocatore` in `supabase/migrations/`. Conservate solo come riferimento. |
@@ -1,89 +0,0 @@
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle preesistenti.
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
-- `profili-giocatore` creato dalla migration M3.
CREATE TABLE public.profili_giocatore (
giocatore_id text PRIMARY KEY REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
-- Dati personali richiesti dal tesseramento CSI
data_nascita date,
luogo_nascita text,
indirizzo text,
telefono text,
email text,
-- Documento di identità
documento_tipo text,
documento_numero text,
documento_rilasciato_da text,
documento_emissione date,
documento_scadenza date,
-- Il documento si carica fronte e retro: il CSI li vuole entrambi.
documento_fronte_path text,
documento_retro_path text,
-- Certificato medico (storico non conservato in v1: DD-010)
certificato_scadenza date,
certificato_path text,
-- Foto tessera
foto_path text,
creato_il timestamptz NOT NULL DEFAULT now(),
aggiornato_il timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.profili_giocatore IS
'Dati personali, documento e certificato di ciascun giocatore, 1:1 con giocatori_squadra. Vedi DD-016.';
CREATE TRIGGER update_profili_giocatore_aggiornato_il
BEFORE UPDATE ON public.profili_giocatore
FOR EACH ROW
EXECUTE FUNCTION public.update_aggiornato_il();
ALTER TABLE public.profili_giocatore ENABLE ROW LEVEL SECURITY;
-- Il giocatore vede e modifica solo il proprio profilo: il collegamento passa
-- da giocatori_squadra.auth_user_id, che solo un admin può riassegnare (M1).
CREATE POLICY "Il giocatore legge il proprio profilo" ON public.profili_giocatore
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
);
CREATE POLICY "Il giocatore crea il proprio profilo" ON public.profili_giocatore
FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
);
CREATE POLICY "Il giocatore aggiorna il proprio profilo" ON public.profili_giocatore
FOR UPDATE TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
);
-- Gli admin leggono tutti i profili ed esportano i dati per il tesseramento.
CREATE POLICY "Gli admin gestiscono tutti i profili" ON public.profili_giocatore
FOR ALL TO authenticated
USING (public.has_role(auth.uid(), 'admin'::public.app_role))
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
GRANT SELECT, INSERT, UPDATE ON public.profili_giocatore TO authenticated;
GRANT ALL ON public.profili_giocatore TO service_role;
@@ -1,36 +0,0 @@
-- M3 — Bucket privato per documenti, certificati e foto tessera (DD-016 regole 3 e 4)
-- Migration additiva. Il bucket nasce privato e resta privato: documenti d'identità e
-- dati sanitari non devono mai essere raggiungibili da un URL pubblico. L'accesso avviene
-- solo con client autenticato o con signed URL a scadenza breve generata per gli admin.
INSERT INTO storage.buckets (id, name, public)
VALUES ('profili-giocatore', 'profili-giocatore', false)
ON CONFLICT (id) DO NOTHING;
-- Convenzione dei path: `<giocatore_id>/<sezione>.<estensione>` (es. `g4/certificato.pdf`).
-- La prima cartella è l'ID del giocatore: è così che si riconosce il proprietario del file.
CREATE POLICY "Il giocatore gestisce i propri file" ON storage.objects
FOR ALL TO authenticated
USING (
bucket_id = 'profili-giocatore'
AND EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid()
)
)
WITH CHECK (
bucket_id = 'profili-giocatore'
AND EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid()
)
);
-- Gli admin scaricano i file di tutti, ma non li modificano: i documenti restano
-- in mano al giocatore che li ha caricati.
CREATE POLICY "Gli admin scaricano tutti i file dei profili" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'profili-giocatore'
AND public.has_role(auth.uid(), 'admin'::public.app_role)
);
-24
View File
@@ -1,24 +0,0 @@
# DD-001 — CrAPP deve restare indipendente da Lovable
**Data:** luglio 2026
**Stato:** Accettata
**Contesto**
Il progetto nasce come prototipo su Lovable Cloud. Per crescere serve controllo su codice, deploy, database e costi.
**Decisione**
Spostare lo sviluppo su repository GitHub indipendente, con deploy su Vercel e database Supabase gestito dal team.
**Alternative scartate**
- Restare su Lovable come unica piattaforma → troppa dipendenza da un servizio esterno.
- Riscrivere tutto da zero → costo e rischio inutili; il prototipo funzionava già.
**Conseguenze**
- Maggiore libertà e responsabilità per il team.
- Restano tracce del passaggio (dipendenze, meta tag): vanno eliminate gradualmente, non in blocco.
- L’app deve poter girare anche fuori dall’ecosistema Lovable (vedi `PORTABILITA.md`).
**Riesame**
Quando il progetto non userà più alcun componente Lovable.
-24
View File
@@ -1,24 +0,0 @@
# DD-002 — Sviluppo document-first
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
Con più persone (e assistenti AI) che lavorano sul codice, serviva un modo per evitare funzionalità “inventate” al volo e incoerenze tra moduli.
**Decisione**
Ogni nuova funzionalità significativa viene prima **progettata e documentata** in `docs/modules/`, poi implementata. Il flusso ufficiale è: idea → progettazione → documentazione → database → codice → test → release.
**Alternative scartate**
- Documentare solo a posteriori → troppo spesso incompleto o assente.
- Affidarsi solo al codice come documentazione → illeggibile per chi non programma.
**Conseguenze**
- Rallenta leggermente l’avvio di nuove feature, ma riduce rework e discussioni infinite.
- I moduli preesistenti vanno retro-documentati quando possibile.
- Nessuna feature non documentata entra in produzione.
**Riesame**
Se il team diventa molto piccolo e la documentazione smette di essere consultata.
-26
View File
@@ -1,26 +0,0 @@
# DD-003 — Due branch: main stabile, develop per il lavoro
**Data:** agosto 2026
**Stato:** Sostituita da [DD-019](DD-019.md) (settembre 2026)
**Contesto**
Serve separare ciò che i giocatori usano ogni giorno da ciò che è ancora in prova.
**Decisione**
- `main` → produzione, sempre funzionante, deploy automatico.
- `develop` → sviluppo e preview, merge su `main` solo dopo test.
**Alternative scartate**
- Sviluppare direttamente su `main` → rischio di rotture in produzione.
- Branch per ogni feature → eccessivo per la dimensione attuale del team.
**Conseguenze**
- Gli utenti in produzione non vedono lavori incompleti.
- Ogni release su `main` deve includere verifica delle funzionalità esistenti.
**Riesame**
Sostituita: nella pratica il lavoro è finito direttamente su `main` e `develop` è rimasto
indietro. Vedi DD-019.
-22
View File
@@ -1,22 +0,0 @@
# DD-004 — Ogni versione aggiunge, non riscrive
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
CrAPP è già usata dalla squadra per presenze, calendario, scout, badge e notifiche. Rischiare regressioni su moduli funzionanti vanifica la fiducia degli utenti.
**Decisione**
Le nuove versioni **introducono** funzionalità. Non si riscrive un modulo già operativo salvo richiesta esplicita e pianificata.
**Alternative scartate**
- Refactoring ampio “per pulire” insieme a ogni release → alto rischio, poco valore immediato per gli utenti.
**Conseguenze**
- Coesistono temporaneamente soluzioni vecchie e nuove (es. dati hardcoded accanto a tabelle database).
- Il debito tecnico va gestito con migration dedicate, non di nascosto.
**Riesame**
Quando un modulo diventa ingestibile o blocca una release importante.
-23
View File
@@ -1,23 +0,0 @@
# DD-005 — Mobile-first, pochi click, pochi schermi
**Data:** origine progetto
**Stato:** Accettata
**Contesto**
I giocatori usano l’app soprattutto da smartphone, spesso in spogliatoio o in palestra, con poco tempo e poca pazienza.
**Decisione**
Interfaccia semplice, veloce, ottimizzata per telefono. Navigazione ridotta (barra inferiore). Ogni schermata deve avere uno scopo chiaro.
**Alternative scartate**
- Layout da desktop con menu complessi → scomodo in mobilità.
- App nativa iOS/Android → costi e tempi di pubblicazione non giustificati per una squadra amatoriale.
**Conseguenze**
- Funzionalità amministrative complesse vanno semplificate o suddivise con cura.
- La PWA è la forma giusta per questo pubblico.
**Riesame**
Se emergono esigenze desktop forti (es. gestione documenti massiva solo da PC).
-22
View File
@@ -1,22 +0,0 @@
# DD-006 — Intelligenza artificiale solo se porta beneficio reale
**Data:** origine progetto
**Stato:** Accettata
**Contesto**
L’AI è attraente ma può complicare l’app, aumentare i costi e creare aspettative irrealistiche.
**Decisione**
Usare l’AI solo quando riduce lavoro agli admin o migliora concretamente l’esperienza dei giocatori. Non introdurla “perché si può”.
**Alternative scartate**
- AI ovunque (chatbot, suggerimenti automatici, analisi predittive) → fuori focus per una squadra amatoriale.
**Conseguenze**
- “AI Allenamenti” è tra le voci ancora da fare in roadmap.
- Ogni proposta AI va valutata con la domanda: _chi risparmia tempo e quanto?_
**Riesame**
Quando l’AI diventa economica e affidabile per casi d’uso chiari (es. generazione allenamenti).
-23
View File
@@ -1,23 +0,0 @@
# DD-007 — Badge calcolati dall’app, non salvati nel database
**Data:** origine progetto
**Stato:** Accettata
**Contesto**
I badge dipendono da statistiche già disponibili (presenze, MVP, cacche, ecc.). Salvare ogni badge sbloccato nel database aggiungerebbe complessità senza beneficio immediato.
**Decisione**
I badge vengono **calcolati al volo** dall’applicazione in base ai dati esistenti. Non esiste una tabella badge dedicata.
**Alternative scartate**
- Tabella `badge_sbloccati` con storico → utile in futuro per notifiche retroattive o audit, ma non necessaria ora.
**Conseguenze**
- Meno migration e meno sincronizzazione.
- Lo “sblocco” celebrativo usa cache locale per non ripetere animazioni.
- Un eventuale storico badge richiederà una nuova decisione.
**Riesame**
Se servono badge manuali assegnati dagli admin o storico immutabile.
-25
View File
@@ -1,25 +0,0 @@
# DD-008 — Gamification equa tra ruoli
**Data:** origine progetto
**Stato:** Accettata
**Contesto**
In pallavolo i ruoli hanno statistiche diverse (un libero non segna punti d’attacco). Confrontare tutti sugli stessi numeri sarebbe ingiusto e scoraggiante.
**Decisione**
Le statistiche **personali** in profilo e squadra devono essere **eque per tutti i ruoli**. Dati tecnici di reparto (punti, ace, muri) restano nello Scout Live come informazione di squadra, non come leva competitiva individuale.
**Alternative scartate**
- Classifiche individuali basate su punti → penalizza libero, palleggiatore, centrale.
**Conseguenze**
- Badge e obiettivi usano presenze, MVP, pagelle, serie, cacche — metriche accessibili a tutti.
- Lo scout resta strumento tecnico, non gioco.
- Aggiornamento 2026-11-11: in Profilo → Season compaiono, come semplice informazione personale,
i totali di ace, attacchi, muri e punti del giocatore ricavati dai + dello scout. Non entrano
in badge, obiettivi o classifiche: la regola sulla leva competitiva resta valida.
**Riesame**
Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
-24
View File
@@ -1,24 +0,0 @@
# DD-009 — Tesseramento CSI manuale, integrazione API in una fase successiva
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
Il modulo Profilo Giocatore deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente.
**Decisione**
- **Prima fase:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI.
- **Fase successiva:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale).
**Alternative scartate**
- Integrazione CSI già nella prima fase → scope troppo ampio, dipendenza da API esterne non controllate.
**Conseguenze**
- Gli admin guadagnano subito tempo (niente più Excel e chat per i documenti).
- L’export CSV deve essere affidabile e completo: è il deliverable chiave di questa prima fase.
**Riesame**
Quando il CSI mette a disposizione API stabili o quando il volume di tesseramenti giustifica l’automazione.
-23
View File
@@ -1,23 +0,0 @@
# DD-010 — Profilo giocatore: niente storico certificati
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
Il certificato medico va aggiornato ogni stagione. Tenere lo storico di tutte le versioni complica upload, storage e privacy.
**Decisione**
Il giocatore può **sovrascrivere** certificato e data di scadenza. Lo storico delle versioni precedenti non viene conservato.
**Alternative scartate**
- Archivio certificati → utile per audit, rinviato a versioni future.
**Conseguenze**
- Implementazione più semplice e veloce.
- Gli admin vedono solo il certificato attuale.
- Va comunicato chiaramente ai giocatori che sostituire il file elimina quello precedente.
**Riesame**
Se il CSI o il regolamento interno richiedono conservazione storica.
-24
View File
@@ -1,24 +0,0 @@
# DD-011 — Autenticazione reale prima del profilo amministrativo completo
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
Oggi l’app identifica l’utente con la selezione del giocatore da una lista, senza login. Documenti, certificati e dati personali richiedono sapere _chi_ sta operando e impedire accessi non autorizzati.
**Decisione**
Prima di completare il modulo Profilo Giocatore, introdurre **login con Google o email** tramite Supabase Auth — non tramite Lovable Auth. Dopo il login, il giocatore associa il proprio profilo squadra.
**Alternative scartate**
- Continuare solo con selezione da lista → inaccettabile per dati sensibili.
- Lovable Auth → crea dipendenza da piattaforma che stiamo abbandonando.
**Conseguenze**
- Tutti dovranno fare login almeno una volta.
- Gli admin useranno ruoli veri (`user_roles`), non una lista di nomi hardcoded.
- È prerequisito per dashboard admin e export CSI.
**Riesame**
Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali).
-22
View File
@@ -1,22 +0,0 @@
# DD-012 — Rimandare la migrazione degli ID giocatore
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
L’app usa identificativi semplici (`g1`, `g2`, …) collegati a presenze, voti, palloni e altre funzioni già in uso. Nel database esiste anche una tabella `giocatori` con UUID, non collegata al codice attuale.
**Decisione**
Per ora **non** unificare gli ID. I nuovi dati del profilo si agganciano agli identificativi già in uso. La migrazione verso UUID resta un lavoro separato, pianificato e testato.
**Alternative scartate**
- Migrare subito tutto a UUID → rischio alto di rompere presenze, voti, scout e notifiche.
**Conseguenze**
- Coesistono due modelli anagrafici fino a migration dedicata.
- `DATABASE.md` va tenuto aggiornato su cosa è “attivo” e cosa è “futuro”.
**Riesame**
Quando il modulo Profilo Giocatore è stabile e c’è tempo per una migration con checklist regressioni completa.
-22
View File
@@ -1,22 +0,0 @@
# DD-013 — Portabilità: l’app non deve dipendere da servizi esclusivi
**Data:** luglio 2026
**Stato:** Accettata
**Contesto**
La squadra potrebbe voler cambiare hosting, database o fornitore auth in futuro.
**Decisione**
CrAPP deve poter girare su **Node.js + PostgreSQL standard**. Niente funzionalità bloccate su servizi proprietari. I dati si accedono solo tramite moduli in `src/lib/`, non direttamente dai componenti.
**Alternative scartate**
- Accettare lock-in per velocità → contrario alla lunga vita del progetto.
**Conseguenze**
- Supabase va bene perché è PostgreSQL e self-hostable.
- Le API push e i job restano endpoint HTTP richiamabili da qualsiasi scheduler.
**Riesame**
Se si adotta un servizio che viola questa regola.
-16
View File
@@ -1,16 +0,0 @@
# DD-014 — Convergenza schema database (eventi e presenze)
**Data:** —
**Stato:** In valutazione
**Contesto**
Esistono due modelli paralleli: tabelle “legacy” usate dall’app (`eventi_app`, `risposte_presenze`) e tabelle “nuove” con autenticazione e vincoli (`eventi`, `presenze`, `giocatori` UUID).
**Decisione proposta**
Unificare gradualmente sul modello autenticato, dopo auth e profilo stabili.
**Perché non ora**
Rischio regressioni su calendario e presenze, moduli più usati della squadra.
**Riesame previsto**
Dopo che il modulo Profilo Giocatore sarà stabile, con migration e test dedicati.
-30
View File
@@ -1,30 +0,0 @@
# DD-015 — Rosa anagrafica: da codice hardcoded a database
**Data:** 3 settembre 2026
**Stato:** Accettata
**Contesto**
La lista giocatori viveva nel codice sorgente (`src/lib/crapp-data.ts`). Il database aveva già
`giocatori_squadra` (migration M1) popolata ma non letta da nessuna schermata tranne
`/benvenuto` e `/admin`: «Aggiungi giocatore» e «Disattiva giocatore» della dashboard non
avevano effetto su Squadra, Presenze, Pagelle, Badge e Scout, mantenendo gli stessi ID finché
non si farà DD-012.
**Decisione**
`useRosa()` (e con lei `useIo`, `useObiettivi`) legge ora `giocatori_squadra` tramite
`useGiocatoriSquadra()`, filtrando solo i giocatori `attivo`. Tutti i punti che prima
importavano la lista statica (`convocatiEvento`, `compleanniEventi`, `completaTurni`,
`csvScoutMatch`, i widget di voto/scout/palloni, le due route API che mandano push) sono stati
agganciati allo stesso hook o, lato server, a `leggiGiocatoriSquadra()`
(`src/lib/giocatori-squadra.server.ts`, stesso pattern di `eventi.server.ts`).
**Conseguenze**
- Un giocatore aggiunto o disattivato dalla dashboard admin ora si riflette ovunque, non solo
in `/benvenuto` e `/admin`.
- `giocatori_squadra` non ha ancora una colonna per la data di nascita: per i 17 giocatori
storici resta quella di `crapp-data.ts` (`nascitaPerId`, lookup per id); un giocatore
aggiunto dopo la migrazione non ha nascita nota finché la colonna non esiste. Follow-up da
aprire quando serve davvero.
- `src/lib/crapp-data.ts` resta come seed storico e fallback (`rosaFallback()`), non più come
fonte viva.
-45
View File
@@ -1,45 +0,0 @@
# DD-016 — Schema dati Profilo Giocatore (F0)
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con l’anagrafica attuale (`g1`…`g17` nel codice) e con la tabella `giocatori` UUID già presente ma non usata. Serviva una scelta chiara su dove salvare i dati, come collegare l’autenticazione e come proteggere documenti sensibili — senza toccare le tabelle preesistenti già operative.
**Decisione**
Si introducono **due nuove tabelle additive**:
- **`giocatori_squadra`** — anagrafica squadra con ID testuali (`g1`…`g17`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento account (`auth_user_id`).
- **`profili_giocatore`** — dati personali, metadati documento identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`.
Regole vincolanti:
1. **`giocatori_squadra` diventa progressivamente la source of truth** per l’anagrafica squadra. Durante la transizione, `crapp-data.ts` resta come **fallback** se il database non è disponibile o i dati non sono ancora migrati.
2. L’associazione **`auth_user_id` ↔ giocatore** è un’operazione **controllata e atomica** (es. al primo accesso da `/benvenuto`, con `UPDATE … WHERE auth_user_id IS NULL`). Il giocatore **non può modificare liberamente** `auth_user_id`; solo un admin può resettarlo in casi eccezionali.
3. I file (documento identità, certificato; la foto tessera è stata tolta da DD-044) vivono nel bucket Storage **`profili-giocatore`**, configurato come **privato**.
4. Documenti personali e sanitari **non devono mai essere esposti tramite URL pubblici**. Accesso solo tramite client autenticato con policy RLS, o signed URL a scadenza breve per download admin.
5. Le **tabelle preesistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1`…`g17` già in uso, senza migrare verso UUID per ora (coerente con DD-012).
6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo.
**Alternative scartate**
- Estendere la tabella `giocatori` UUID → conflitto con ID operativi del codice e rischio di regressioni.
- Salvare file come base64 nel database → ingestibile, difficile da gestire e da scaricare.
- Bucket pubblico con URL permanenti → inaccettabile per dati sanitari e documenti d’identità.
- Permettere al giocatore di cambiare `auth_user_id` liberamente → rischio di impersonazione e race condition.
- Modificare le tabelle preesistenti per aggiungere FK verso il profilo → viola DD-004 e DD-012.
**Conseguenze**
- Coesistono temporaneamente tre rappresentazioni dell’anagrafica: `crapp-data.ts` (fallback), `giocatori_squadra` (target), `giocatori` UUID (dormiente).
- `src/lib/rosa.ts` legge dal database e ricade su `crapp-data.ts` in caso di errore o assenza dati (DD-015).
- Il completamento profilo (30/30/30/10) si calcola in app, non si persiste nel database.
- Lo storico certificati non viene conservato (coerente con DD-010).
- Le migration M1–M2 (tabelle, RLS, bucket) restano **additive**: solo `CREATE`, nessun `ALTER`/`DROP` su schema esistente.
- Raffina e attua quanto proposto in DD-015 per la rosa anagrafica, senza sostituire formalmente quella voce.
**Riesame**
- Quando `giocatori_squadra` è stabile in produzione e il fallback `crapp-data.ts` non serve più.
- Quando si pianifica la convergenza verso UUID (DD-012, dopo che il profilo sarà stabile).
- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati.
-37
View File
@@ -1,37 +0,0 @@
# DD-017 — L'amministratore può compilare i dati al posto del giocatore
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
Il modulo Profilo Giocatore era costruito su un confine netto: ognuno scrive solo la propria riga, l'amministratore legge e scarica. Nella pratica quel confine blocca il lavoro che il modulo doveva togliere: se metà squadra non compila i propri dati, l'export per il tesseramento CSI resta incompleto e l'admin torna a chiedere le informazioni in chat — esattamente ciò che CrAPP deve eliminare. Inoltre le docs assegnavano già agli admin la gestione dei dati squadra (nome, cognome, numero, ruolo) e il reset del collegamento all'account (DD-016 regola 2), senza che esistesse una schermata per farlo.
**Decisione**
Dalla dashboard amministratore, un admin può:
1. modificare i **dati squadra** di qualsiasi giocatore (nome, cognome, numero, ruolo);
2. compilare e correggere i **dati personali e del documento** di qualsiasi giocatore;
3. **scollegare** un account da un profilo, liberando lo slot.
Restano fuori, e non cambiano:
- i **file** (documento, certificato; la foto tessera è stata tolta da DD-044): l'admin li scarica ma non li carica né li sostituisce. Un documento d'identità lo produce il suo titolare, e la catena di responsabilità deve restare leggibile;
- il **giocatore**, che continua a non poter toccare i propri dati squadra.
**Alternative scartate**
- Lasciare tutto al giocatore → l'export CSI resta incompleto e il lavoro amministrativo torna in chat, contro la missione del progetto.
- Dare all'admin anche l'upload dei file → confonde chi ha fornito un documento, su dati sanitari e d'identità dove serve il contrario.
- Un ruolo intermedio (segreteria) per i soli dati personali → un ruolo in più per una squadra sola, con gli stessi tre amministratori di adesso.
**Conseguenze**
- Il modello dei permessi non è più "ognuno i suoi": è "ognuno i suoi, più l'admin su tutti, tranne i file". Le policy RLS di M1 e M2 lo consentivano già, quindi non servono migration.
- Un admin può correggere un errore di battitura in un numero di documento senza inseguire il giocatore.
- Un admin vede e scrive dati personali altrui: è un potere reale, dato a tre persone su diciassette. Va assegnato con la stessa cura di prima (una riga in `user_roles`, nessuna auto-promozione).
- Il completamento del profilo smette di essere un indicatore di _chi ha risposto_ e diventa un indicatore di _quali dati mancano_, chiunque li abbia inseriti.
**Riesame**
- Se la squadra cresce al punto da rendere sensato un ruolo di sola segreteria.
- Se serve tracciare _chi_ ha modificato un dato: oggi non c'è audit, e con la scrittura condivisa la domanda prima o poi arriva.
-25
View File
@@ -1,25 +0,0 @@
# DD-018 — Collegamento automatico giocatore↔account per email
**Data:** settembre 2026
**Stato:** Accettata
**Contesto**
DD-016 regola 2 prevedeva che, al primo accesso, il giocatore scegliesse manualmente il proprio slot libero da un elenco (`/benvenuto`). In pratica ogni giocatore ha un'email nota (o presto nota), quindi far scegliere un nome da una lista è un passaggio superfluo e un rischio: un giocatore può selezionare per errore lo slot di un compagno, e nulla nel flusso lo impedisce a livello di prodotto.
**Decisione**
Al primo accesso, `giocatori_squadra` viene interrogata per email (case-insensitive, tramite la nuova colonna `email`) invece di mostrare un elenco di slot liberi. Se l'email dell'account Google corrisponde a una riga libera, il collegamento avviene automaticamente. Se non corrisponde a nessuna riga (email non ancora nota, o nessun profilo per quella persona), l'utente vede solo un messaggio d'errore che invita a contattare un amministratore, con un pulsante per uscire e riprovare con un altro account — nessuna selezione manuale di ripiego. Le email sono popolate via migration (`m5_email_giocatori_squadra`) per la rosa iniziale; un'interfaccia in `/admin` per impostarle su nuovi giocatori è arrivata poco dopo (vedi "Alternative scartate"). Il trigger `enforce_giocatori_squadra_update` (DD-016) viene esteso per richiedere anche la corrispondenza email, non solo lo slot libero: il vincolo resta nel database, non solo nella UI.
**Alternative scartate**
- Mantenere la selezione manuale come ripiego quando l'email non trova corrispondenza → scartata: vanificherebbe la garanzia "ognuno collega solo il proprio profilo" e reintrodurrebbe il rischio di scelta errata che questa decisione vuole eliminare.
- Un'interfaccia admin per scrivere l'email dei giocatori → rimandata al momento della decisione, poi implementata nel form "Aggiungi giocatore" di `/admin` (`src/routes/admin.tsx`): serviva per collegare i giocatori aggiunti a metà stagione senza passare da una nuova migration ogni volta.
**Conseguenze**
- Le righe senza email nota restano bloccate — nessuno può collegarle, nemmeno per errore — finché un admin non la imposta da `/admin` (o, per la rosa iniziale, una migration). Da settembre 2026 tutta la rosa attiva ha l'email registrata.
- `slotLiberi` (funzione ed elenco "slot liberi" in `/benvenuto`) è stato rimosso: non aveva più chiamanti in produzione dopo il cambio.
- Un utente che accede con l'account Google sbagliato resta bloccato su `/benvenuto` finché non esce e riprova con l'account giusto.
**Riesame**
- Se in futuro serve un'assistenza admin diretta dal flusso di login invece che da `/admin`.
-33
View File
@@ -1,33 +0,0 @@
# DD-019 — Il branch dei commit lo decide l'utente
**Data:** 4 settembre 2026
**Stato:** Accettata — sostituisce [DD-003](DD-003.md)
**Contesto**
DD-003 prevedeva di lavorare su `develop` e portare su `main` solo dopo i test. Nella pratica
è successo il contrario: `main` è arrivato a 43 commit di vantaggio su `develop`, che è rimasto
fermo. Una regola che nessuno segue è peggio di nessuna regola, perché rende inaffidabile tutto
il resto del documento — e con più assistenti AI in gioco il rischio vero non era il branch
sbagliato, ma un agente che committa o pusha per conto suo.
**Decisione**
È l'utente a dire su quale branch va un commit. L'assistente può **consigliare** un branch
dedicato quando la modifica è rischiosa o parallela ad altro lavoro, ma non cambia branch, non
committa, non fa push e non apre PR di propria iniziativa. In assenza di indicazioni si lavora
dove si trova il repository, di fatto `main`.
**Alternative scartate**
- Tenere DD-003 e riallineare `develop` → si sarebbe rotta di nuovo alla prima fretta.
- Dismettere `develop` → si perderebbero le preview Vercel, utili quando servono davvero.
**Conseguenze**
- `main` è insieme produzione e branch di lavoro: ogni commit deve lasciare l'app funzionante,
quindi la rete di sicurezza sono i test (vedi DD-020), non il branch.
- `develop` esiste ancora ma è indietro: la sua preview Vercel non rappresenta lo stato attuale
finché non viene riallineata.
**Riesame**
Se il team cresce oltre una persona che scrive codice, o se un lavoro lungo ha bisogno di stare
fuori produzione per più di qualche giorno.
-31
View File
@@ -1,31 +0,0 @@
# DD-020 — Una funzione modificata senza test non è finita
**Data:** 4 settembre 2026
**Stato:** Accettata
**Contesto**
Con `main` come branch di lavoro (DD-019) non c'è più un ambiente di prova tra il codice e i
giocatori. La suite in `test/` esisteva già ma scriverla era di fatto facoltativo, e i difetti
trovati dai test sono arrivati a posteriori (la sessione Scout Live che non scadeva mai, le
serie di presenze ferme a zero per settimane).
**Decisione**
Chi aggiunge o modifica una funzione scrive o aggiorna il test nello stesso lavoro, e i test
devono essere verdi prima di consegnare. Non si commenta un test che fallisce né si indebolisce
un'asserzione per farla passare: se il comportamento voluto è cambiato, si aggiorna il test
dicendo perché.
**Alternative scartate**
- Test solo sui moduli critici → il confine «critico» si sposta a ogni fretta.
- Introdurre un framework di test → la suite bun con `node:assert` funziona e non aggiunge
dipendenze (vedi [test/README.md](../../test/README.md)).
**Conseguenze**
- La logica di dominio va tenuta separabile dagli hook (`*-core.ts`), altrimenti non è
testabile in `test/unit/` senza rete.
- Le modifiche costano un po' di più; le regressioni in produzione costano di più.
**Riesame**
Se comparisse un ambiente di staging stabile che rende superflua parte della copertura.
-40
View File
@@ -1,40 +0,0 @@
# DD-021 — Molle interrompibili al posto delle animazioni a durata fissa
**Data:** 5 settembre 2026
**Stato:** Accettata
**Contesto**
Il movimento era fatto con `@keyframes` CSS e transizioni a durata fissa. Funzionava, ma
nessuna di quelle animazioni può essere interrotta: se l'utente tocca o scorre a metà, la
sequenza va avanti per conto suo, e per ripartire deve prima finire. Mancava del tutto
qualsiasi gesto: il calendario si cambiava solo con due frecce.
Contemporaneamente 43 componenti su 45 in `src/components/ui/` non erano importati da nessuna
parte, e con loro ~45 dipendenze (tutti i `@radix-ui/*`, `recharts`, `react-hook-form`,
`date-fns`, `embla`, `cmdk`, …): superficie di aggiornamento e di sicurezza pagata a vuoto.
**Decisione**
Aggiungere **una** libreria di animazione — `motion` — e toglierne ~45 inutilizzate. I
parametri stanno in `src/lib/molla.ts` e sono i due di Apple (_Designing Fluid Interfaces_):
rimbalzo e durata, non massa/rigidità/smorzamento. `molla.ui` (nessun sorpasso) è il default;
`molla.slancio` si usa **solo** dopo un gesto che portava già inerzia.
**Alternative scartate**
- Tenere solo CSS con easing `linear()` e View Transitions → copre le comparse, non i gesti:
niente handoff di velocità, niente ripartenza dal valore corrente.
- GSAP → più grande e orientato alla timeline, cioè al modello prescritto che stiamo lasciando.
**Conseguenze**
- Le animazioni partono dal valore _a schermo_: un dato che cambia a metà transizione non
produce salti.
- Il movimento è ora codice JavaScript: senza JS non c'è comparsa (l'app già non funziona
senza, per auth e dati).
- `src/lib/motion.ts` resta solo per il movimento ridotto e i coriandoli.
- `src/components/ui/` non è più una libreria: aggiungere una primitiva shadcn significa
installarla, non pescarla da lì.
**Riesame**
Se il peso del bundle client diventasse un problema misurato, o se il web recuperasse
nativamente l'interrompibilità (`ScrollTimeline` e `linear()` sono un primo passo).
-32
View File
@@ -1,32 +0,0 @@
# DD-022 — L'app è solo chiara
**Data:** 5 settembre 2026
**Stato:** Accettata
**Contesto**
`styles.css` conteneva un blocco `.dark` completo che non veniva mai applicato: nessun
interruttore, nessun `prefers-color-scheme`. Peggio, era incoerente. In `.dark` l'accento
diventava grigio-blu — il rosso del brand spariva — e mancavano del tutto `--success`,
`--warning`, `--info`, `--training`, i metalli dei badge, i due gradienti e le due ombre. Un
terzo stato: presente, sbagliato, morto.
**Decisione**
CrAPP è un'app solo chiara. Il blocco `.dark` è rimosso e `:root` dichiara
`color-scheme: light`, così anche i controlli nativi restano coerenti.
**Alternative scartate**
- Completare il tema scuro → è lavoro vero (gradienti, ombre, i due colori dei metalli, le
superfici traslucide) per una richiesta che nessuno ha fatto.
- Lasciare il blocco lì «per dopo» → un tema mai attivato non si accorge di rompersi.
**Conseguenze**
- Chi riaprirà il tema scuro parte da zero, ma da zero onesto: la palette chiara ha ora
contrasti verificati e i token con suffisso `-testo` per i colori che come testo non
reggono.
- La barra di stato (`theme-color`) e lo splash del manifest sono allineati al fondo chiaro.
**Riesame**
Se arriva una richiesta reale dalla squadra, o se si gioca in palestre al buio abbastanza
spesso da rendere il tema scuro una funzione e non un vezzo.
-59
View File
@@ -1,59 +0,0 @@
# DD-023 — Ogni scrittura è limitata a chi la fa
**Data:** 6 settembre 2026
**Stato:** Accettata
**Contesto**
Le tabelle preesistenti sono nate con policy `USING (true)` per `anon, authenticated`. M4
(DD-011) ha tolto il GRANT ad `anon`, e la cosa è stata letta come «ora è chiuso». Non lo
era: per gli autenticati non c'era rimasto nessun limite. Verificato sul database locale con
un utente appena creato, senza ruolo e senza slot nella rosa: `POST /rest/v1/eventi_app` →
201, `DELETE` → 200. Qualsiasi giocatore loggato poteva svuotare il calendario della squadra
o riscrivere il voto pagella di un altro, parlando direttamente con PostgREST — senza
passare dall'interfaccia, che quei pulsanti glieli nasconde.
Il permesso viveva quindi solo nei componenti (`useIsAdmin()`, `io.id`), cioè nel posto che
un attaccante non usa.
**Decisione**
Le policy rispecchiano quello che l'interfaccia già fa. Tre gruppi:
- **solo admin**: `eventi_app`. Nell'app li scrive unicamente la rotta `/eventi`, che è già
riservata agli amministratori.
- **solo la propria riga**: `risposte_presenze`, `cacche_partita` (per `giocatore_id`),
`pagelle_voti`, `mvp_voti`, `badge_social_voti` (per `votante_id`). L'admin resta incluso,
perché DD-017 gli riconosce già il diritto di agire al posto del giocatore.
- **invariate**: `turni_palloni` e le tre tabelle scout. Nell'interfaccia non hanno nessun
gate — il turno palloni se lo passa chiunque, e chiunque può aprire lo Scout Live —
quindi stringerle sarebbe una funzionalità nuova, non una messa in sicurezza.
L'identità è lo slot di `giocatori_squadra` collegato all'account, espresso con lo stesso
`EXISTS` che usano le policy dei profili — la funzione `mio_giocatore_id()` di M2 è stata
rimossa dalla migration di correzione e non si reintroduce. Regge perché `io.id` non è una
scelta libera: dopo DD-011 e DD-018 il `localStorage` viene forzato sullo slot collegato
all'account (`benvenuto.tsx`), e senza slot non si entra.
**Alternative scartate**
- Lasciare tutto aperto e documentarlo → si può difendere per una squadra di venti persone
che si conoscono, ma non regge il primo account che passa di mano o il primo telefono
perso, e rende ogni bug indistinguibile da un dispetto.
- Controllare i permessi nelle route server → CrAPP scrive dal client con supabase-js. Ci
vorrebbe un livello API che oggi non esiste, per ottenere quello che la RLS fa da sola.
- Stringere anche palloni e scout → cambierebbe come funziona la squadra, e nessuno l'ha
chiesto.
**Conseguenze**
- Un giocatore che non ha ancora collegato lo slot non scrive più niente: l'`EXISTS` non
trova nessuna riga. È lo stesso muro di `/benvenuto`, ora applicato anche al database.
- La lettura resta aperta a tutti gli autenticati, pagelle comprese: chi interroga PostgREST
può ancora vedere **chi** ha votato cosa. L'anonimato delle pagelle è una scelta di
interfaccia, non una garanzia del database, e questa migration non lo cambia.
- I test in `test/integration/permessi.test.ts` diventano la definizione eseguibile di questa
tabella dei permessi.
**Riesame**
Se l'anonimato delle pagelle deve diventare reale (servirebbe una vista aggregata e la
chiusura della lettura riga per riga), o se turni e scout acquistano un gate
nell'interfaccia: allora le loro policy devono seguirlo.
-60
View File
@@ -1,60 +0,0 @@
# DD-024 — Le route che avvisano la squadra chiedono le credenziali
**Data:** 6 settembre 2026
**Stato:** Accettata
**Contesto**
Le route in `src/routes/api/public/` girano con la service role e saltano la RLS: DD-023 non
le tocca. Nessuna di loro faceva un controllo di accesso — cercando `authorization` in quella
cartella l'unico header era lo User-Agent con cui `csi.ts` chiama il portale CSI. Chiunque
conoscesse l'URL poteva quindi far suonare i telefoni di tutta la squadra:
`promemoria-palloni` accetta perfino una POST con il corpo vuoto.
La difesa apparente delle altre due — «serve un id evento valido» — non è una difesa: l'id è
`e` + il timestamp in base 36 (`nuovoIdEvento()`), compare negli URL che la squadra si
scambia, ed è elencabile da qualsiasi utente loggato.
Il danno non è furto di dati: i testi delle notifiche li costruisce il server. È molestia e
consumo della quota push. Non è però una ragione per lasciare la porta aperta.
**Decisione**
Le tre route che inviano notifiche chiedono le credenziali, con due controlli diversi perché
i chiamanti sono diversi (`src/lib/auth-route.server.ts`):
- `apri-sondaggio` e `sollecita-presenze` → `richiediAdmin`: token della sessione Supabase
verificato con `auth.getUser`, poi ruolo `admin` letto da `user_roles`, la stessa fonte di
`src/lib/ruoli.ts` (DD-011). `401` senza token valido, `403` con token ma senza ruolo. Il
controllo viene **prima** della validazione dell'input, così la risposta non rivela
nemmeno se un evento esiste.
- `promemoria-palloni` → all'inizio `richiediSegreto`, con un segreto da cron; sostituito
subito dopo da `richiediAdmin` quando la route è diventata manuale (DD-025).
`csi`, `push-config` e `push-subscribe` restano aperte: le chiama il browser prima del login,
dove qualsiasi segreto sarebbe pubblico. (`push-messaggio` esisteva per lo stesso motivo ed è
sparita con DD-026.)
**Alternative scartate**
- Un segreto condiviso anche per le due route dell'app → finirebbe nel bundle JavaScript,
cioè pubblico.
- Il middleware `requireSupabaseAuth` già presente nel repository → è
`createMiddleware({ type: "function" })`, protegge le server function di TanStack Start. In
CrAPP `createServerFn` non compare da nessuna parte: quel file non è mai stato eseguito,
e non si applica comunque alle route in `src/routes/api/`.
- Fidarsi dell'id evento come credenziale → è un timestamp in un URL condiviso.
**Conseguenze**
- Nessuna variabile d'ambiente da configurare: dopo DD-025 tutte e tre le route usano lo
stesso controllo sul ruolo.
- I due pulsanti dell'app mandano ora il token con `intestazioniAutenticate()`
(`src/lib/auth.ts`), letto al momento della chiamata e non da uno stato React, così non si
spedisce un token scaduto.
- `api.test.ts` non può più verificare la validazione dell'input di `sollecita-presenze`,
che ora sta dietro all'accesso: quel pezzo si è spostato in
`test/integration/permessi-route.test.ts`, che gira sullo stack locale perché ha bisogno
di utenti veri.
**Riesame**
Se un giorno l'app userà `createServerFn`, il middleware già presente diventa la strada
naturale e questi controlli vanno riletti alla sua luce.
-54
View File
@@ -1,54 +0,0 @@
# DD-025 — Il promemoria palloni lo manda l'admin, per un evento
**Data:** 6 settembre 2026
**Stato:** Accettata
**Contesto**
`promemoria-palloni` era disegnata per un cron quotidiano: calcolava chi è di turno **oggi** e
gli mandava una push. DD-024 l'aveva chiusa con un segreto condiviso, coerente con quel
disegno. Ma nel repository non c'è nessun cron, e `docs/modules/palloni.md` lo annotava già
come «da verificare lato hosting»: nei fatti quel promemoria non è mai partito. Una route che
funziona e che nessuno chiama.
Nel frattempo l'app aveva già il precedente giusto: `apri-sondaggio` è manuale fin dall'inizio
(«Nessun cron: l'invio è manuale», CHANGELOG).
**Decisione**
Il promemoria lo fa partire un amministratore dal pulsante «Avvisa chi è di turno», dentro il
riquadro palloni della pagina evento. La route accetta un `eventoId` e avvisa i destinatari di
**quell'evento** — chi deve prendere i palloni e chi deve riportarli — invece della giornata
corrente. Il controllo di accesso diventa `richiediAdmin` come le altre due, e
`richiediSegreto` con la sua variabile `CRON_SEGRETO` spariscono.
Il testo dell'avviso viaggia dentro la push, cifrato nel payload (DD-026): il service worker
lo mostra così com'è, quindi un avviso mandato il martedì per il sabato arriva col testo
giusto.
**Alternative scartate**
- Un pulsante «manda il promemoria di oggi» in Dashboard → rispecchia la route com'era, ma
premuto un martedì qualsiasi risponderebbe «inviate: 0» e sembrerebbe rotto. L'admin
ragiona per evento, non per giornata.
- Tenere il cron e configurarlo davvero → più lavoro, una variabile d'ambiente da gestire in
ogni ambiente, e nessuno l'aveva chiesto. Un pulsante che funziona batte uno scheduler che
non esiste.
- Calcolare il testo al volo sulla giornata corrente → funziona solo se l'avviso parte il
giorno stesso, cioè proprio il vincolo da cui volevamo uscire.
**Conseguenze**
- Il promemoria è ora una scelta consapevole di un admin, non un automatismo: se nessuno preme
il pulsante, non parte niente. È un passo indietro rispetto all'idea originale, ma un passo
avanti rispetto alla realtà, dove non partiva mai.
- `destinatariPromemoriaPalloni()` non è più usata dalla route ma resta in `palloni-core.ts`.
- Sparisce `CRON_SEGRETO`: nessuna variabile d'ambiente nuova da configurare in nessun
ambiente.
**Riesame**
Se la squadra si accorge che l'admin si dimentica di premere il pulsante. A quel punto il cron
torna utile, e con lui il segreto: DD-024 descrive già come farlo.
---
**Aggiornamento (30 settembre 2026)** — DD-040 affianca al pulsante un invio automatico (poi ridotto a uno solo, 3 ore
prima, da DD-042), per gli eventi con un incaricato confermato; il pulsante e la route restano.
-44
View File
@@ -1,44 +0,0 @@
# DD-026 — Il testo della notifica viaggia dentro la push
**Stato:** accettata · **Data:** settembre 2026
**Contesto**
Le push partivano senza payload: il service worker, appena svegliato, chiedeva a
`/api/public/push-messaggio` che cosa mostrare. Ad app aperta funzionava; ad app chiusa e
telefono bloccato non arrivava niente. È lo scenario per cui il browser dà al worker pochi
secondi di vita: una fetch verso un endpoint che interroga Supabase è esattamente ciò che non
riesce a chiudersi in tempo, e senza `showNotification` non compare nulla. `Urgency: high` e
un timeout di 3 secondi sul recupero del testo avevano attenuato il sintomo, non la causa.
**Decisione**
Titolo e testo viaggiano cifrati nel corpo della push (`aes128gcm`, RFC 8188/8291) con le
chiavi `p256dh` e `auth` già salvate in `push_subscriptions`. Il service worker legge
`event.data.json()` e mostra la notifica: zero rete, zero attesa.
Cadono di conseguenza la route `push-messaggio`, la coda `promemoria_push` (con la sua
scadenza a 12 ore), `messaggioPalloniOggi()` e il timeout nel service worker: tutti pezzi che
esistevano solo per rimediare al payload vuoto. Tutti e tre i mittenti
(`apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`) avevano già il testo pronto
prima di inviare.
**Alternative scartate**
- Usare la libreria `web-push` → dipende da `node:crypto`, e il build nitro ha come target
Cloudflare. La cifratura sta in ~40 righe di Web Crypto, già disponibile ovunque.
- Allungare ancora il timeout della fetch → allunga anche il tempo in cui il worker può
morire prima di mostrare qualcosa. Il problema era la fetch, non la sua durata.
**Conseguenze**
- La notifica non dipende più dalla rete al momento del risveglio, e nemmeno da una funzione
serverless che risponda in fretta a freddo.
- Sparisce l'unico punto in cui chiunque conoscesse un endpoint push poteva leggere il
messaggio destinato a quel dispositivo.
- Il payload sta sotto i 4 KB: i testi attuali sono ampiamente dentro, ma un messaggio molto
lungo andrebbe accorciato.
- La tabella `promemoria_push` resta nel database, ora inutilizzata: va rimossa con una
migrazione quando si tocca lo schema.
**Riesame**
Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push
smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard).
-52
View File
@@ -1,52 +0,0 @@
# DD-027 — Chi vota deve essere convocato, non solo autenticato come sé stesso
**Stato:** accettata · **Data:** 8 settembre 2026
**Contesto**
Un audit del modulo Badge (`docs/modules/badge.md`) ha trovato due filtri rimasti solo
applicativi dopo DD-023: la policy di M11 garantisce che `votante_id` sia lo slot collegato
all'account di chi scrive, ma non controlla che **votante e votato fossero convocati**
all'evento — un utente che scrive direttamente su PostgREST (bypassando l'interfaccia) poteva
votare o essere votato in una partita a cui non aveva partecipato, gonfiando `mediaVoto`,
`mvp` o un badge social a piacere. Allo stesso modo, `eventi_app.pagelle_chiuse` nascondeva
solo i bottoni in UI: un voto pagella "fuori tempo" restava tecnicamente possibile.
**Decisione**
La policy "Ognuno gestisce i propri voti ..." di `pagelle_voti`, `mvp_voti` e
`badge_social_voti` (M11) guadagna un controllo aggiuntivo tramite la funzione
`evento_permette_voto()` (migration `m13_convocati_e_pagelle_chiuse`): verifica che sia
`votante_id` sia `votato_id` compaiano in `eventi_app.convocati` per quel `match_id`
(`convocati` vuoto = tutta la rosa, la stessa convenzione di `convocatiEvento()` in
`eventi.ts`), e — solo per le pagelle — che `pagelle_chiuse` sia falso. Le policy admin
restano invariate e permissive: un amministratore deve poter correggere un voto anche fuori
convocazione o dopo la chiusura.
Il controllo si ferma alla **convocazione**, non alla **presenza reale**: per l'MVP, ad
esempio, l'interfaccia limita già il voto ai soli presenti/in ritardo
(`usePresenzeEvento`), un filtro più stretto che resta solo applicativo — un convocato ma
assente passa ancora a livello database. Stringere fino a quel punto avrebbe richiesto
leggere `risposte_presenze` dentro la policy, un salto di complessità non giustificato
dall'audit che ha originato questa decisione.
**Alternative scartate**
- Un trigger `BEFORE INSERT/UPDATE` invece di RLS → si applicherebbe anche alla service key
e agli admin, bloccando correzioni legittime fuori convocazione; la RLS, applicata solo
alla policy non-admin, li esclude naturalmente.
- Controllare anche la presenza reale (`risposte_presenze`), non solo la convocazione →
scope maggiore del gap trovato in audit, e specifico dell'MVP (pagelle e badge social non
hanno un concetto di "presente" distinto da "convocato" nell'interfaccia attuale).
**Conseguenze**
- Un evento senza `convocati` esplicito (lista vuota, il caso più comune oggi) non cambia
comportamento: tutta la rosa resta votabile, come prima.
- I test di `test/integration/permessi.test.ts` sono la definizione eseguibile anche di
questa parte della tabella dei permessi (voti non convocati rifiutati, `pagelle_chiuse`
rifiutata a database, controlli positivi che provano che un voto legittimo passa ancora).
- Resta un gap conosciuto e documentato (non quello risolto qui): che il votante fosse
davvero presente, non solo convocato, per MVP/pagelle/badge social.
**Riesame**
Se un giorno servisse bloccare anche il voto di un convocato-ma-assente a livello database,
non solo in UI.
-50
View File
@@ -1,50 +0,0 @@
# DD-028 — Soglia minima di campione per Media voto e MVP in home
**Data:** 9 settembre 2026
**Stato:** Accettata
**Contesto**
Un audit della sezione «Colpo d'occhio» in home (`index.tsx`, StatTile Presenze/Media
voto/MVP) ha trovato che due delle tre statistiche non avevano nessun minimo campionario:
`mediePagelle()` calcola una media aritmetica pura, così un giocatore con un solo voto da 10
mostrava "10" in home, più alto di un titolare con 40 voti e media 7.2 — lo stesso problema
che il badge Pagellone già risolve con `VOTI_MINIMI_PAGELLA` (badge.md), ma applicato solo al
badge, non alla StatTile home. Allo stesso modo `mvpVintiPerGiocatore()`/`vincitoriMvp()`
assegnavano un MVP di partita anche con un solo voto totale: bastava che un solo giocatore
votasse perché il votato "vincesse" nettamente, senza nessun quorum di partecipazione.
**Decisione**
- **Media voto** in home usa la stessa soglia del badge Pagellone: sotto `VOTI_MINIMI_PAGELLA`
(5) voti ricevuti, la StatTile mostra `—` invece della media, tramite la funzione pura
`mediaVotoColpoDOcchio()` (`pagelle.ts`), estratta dalla route per restare testabile (DD-020).
La funzione `mediePagelle()` non cambia: il filtro resta lato chiamante, come già faceva il
badge.
- **MVP**: `conteggioPartita`'s aggregazione, tramite `vincitoriMvp()` e
`mvpVintiPerGiocatore()`, richiede ora un quorum minimo di voti totali sulla partita
(`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`) prima di assegnare un vincitore, oltre alla regola già
esistente del margine netto tra primo e secondo. Un solo voto non basta più a incoronare
nessuno, nemmeno in assenza di concorrenza.
**Alternative scartate**
- Alzare la soglia MVP oltre 2 (es. metà dei convocati) → serve conoscere i convocati
dell'evento dentro una funzione che oggi lavora solo sui voti; complessità non giustificata
per il gap trovato in audit.
- Lasciare l'MVP senza quorum e limitarsi al fix della Media voto → il problema di fondo
(un numero esiguo di voti che decide una statistica mostrata come solida) resterebbe aperto
per l'MVP.
**Conseguenze**
- Alcuni MVP di partita già assegnati con un solo voto totale non contano più nel conteggio
`mvp` del giocatore: è una modifica retroattiva al dato mostrato, non solo al calcolo futuro,
perché `mvpVintiPerGiocatore()` deriva sempre il conteggio dai voti grezzi, senza storico
persistito a parte.
- `mediePagelle()` resta invariata: chi la chiama altrove (profilo, squadra) senza applicare la
soglia continua a mostrare la media grezza — non tocca questa decisione, resta il limite già
noto in [pagelle.md](../modules/pagelle.md).
**Riesame**
Se la squadra segnala che il quorum di 2 voti per l'MVP è troppo permissivo o troppo severo, o
se si vuole applicare la stessa soglia di Media voto anche alle StatTile di profilo e squadra.
-77
View File
@@ -1,77 +0,0 @@
# DD-029 — Cancellare un evento pulisce a cascata i dati collegati
**Data:** 9 settembre 2026
**Stato:** Accettata
**Contesto**
Un audit del modulo Obiettivi ha verificato che gli obiettivi in sé non hanno bisogno di
nessuna pulizia quando un evento viene cancellato: sono ricalcolati a runtime sull'elenco
eventi corrente (`obiettivi.ts`), quindi un evento sparito da `eventi_app` semplicemente
smette di contare. Il problema è un livello sotto: `useEliminaEvento()`
(`src/lib/eventi.ts`) cancella solo la riga in `eventi_app`. Nessuna delle otto tabelle
collegate (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`,
`badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) ha mai
avuto una foreign key verso `eventi_app(id)`: le loro righe restavano orfane a database.
Oggi è innocuo per le statistiche, perché nessun calcolo legge quelle tabelle se non partendo
dall'elenco eventi corrente. Ma è un rischio latente: un id evento futuro identico a uno
passato (generato come `"e" + timestamp`, collisione improbabile ma non impossibile)
erediterebbe dati vecchi non suoi; e un calcolo futuro che iterasse direttamente una di quelle
tabelle, invece di partire da `eventi_app`, conterebbe anche le righe orfane.
**Decisione**
Migration `m14_pulizia_dati_evento_cancellato`: un trigger `AFTER DELETE ON eventi_app`
cancella a cascata le righe corrispondenti (`evento_id`/`match_id = id evento cancellato`)
nelle otto tabelle collegate, tramite una funzione `SECURITY DEFINER`. Agisce a database, non
in `useEliminaEvento()`: protegge anche chi cancella un evento scrivendo direttamente su
PostgREST, non solo chi passa dal bottone dell'app.
**Alternative scartate**
- Foreign key con `ON DELETE CASCADE` verso `eventi_app(id)` → non applicabile subito:
`mvp.md` documenta che storicamente `match_id` in `mvp_voti`/`pagelle_voti`/
`badge_social_voti` a volte conteneva l'id di una sessione Scout o di una partita CSI, non
l'id evento CrAPP. Un vincolo FK avrebbe rifiutato la migration alla prima riga storica
disallineata; il trigger non valida i dati esistenti, solo le cancellazioni da qui in avanti.
- Cancellazione manuale nelle otto tabelle dentro `useEliminaEvento()` → fragile: va tenuta
aggiornata a mano ogni volta che un nuovo modulo aggiunge una tabella con `evento_id`, e non
protegge chi scrive/cancella direttamente su PostgREST.
- Bonificare anche le righe orfane già esistenti nella stessa migration → rimandato: tocca dati
reali già scritti, è un intervento più delicato che merita una migration a sé, non urgente
perché quelle righe sono già invisibili a ogni calcolo attuale.
**Conseguenze**
- Da questa migration in poi, cancellare un evento (da qualunque punto, app o REST diretto)
ripulisce automaticamente tutte le tabelle collegate.
- Le righe orfane generate da cancellazioni **precedenti** a questa migration restano nel
database: il trigger previene il problema da qui in avanti, non ripulisce lo storico.
- `test/integration/pulizia-evento.test.ts` è la definizione eseguibile del comportamento:
scrive una riga in ciascuna delle otto tabelle, cancella l'evento e verifica che spariscano
tutte.
**Aggiornamento (9 settembre 2026, stesso giorno)** — la bonifica dello storico prevista sopra
come "riesame" è stata fatta subito dopo, migration `m15_bonifica_dati_evento_orfani`: righe
orfane in `risposte_presenze`, `cacche_partita`, `turni_palloni`, `scout_sessioni`,
`scout_live`, `scout_partite` identificate confrontando `evento_id` con `eventi_app`. Per
`mvp_voti`/`pagelle_voti`/`badge_social_voti` il confronto si applica **solo** ai `match_id`
nel formato id evento CrAPP (`^e[0-9a-z]+$`, quello di `nuovoIdEvento()`): i vecchi voti su id
Scout (prefisso `s` + timestamp decimale) o CSI (numerico o `data-squadra-squadra`) non sono
orfani, sono dati storici legittimi mai collegati a un evento CrAPP (vedi contesto sopra), e la
migration non li tocca. Verificato manualmente sul database locale prima di applicarla: un voto
di test su id Scout è sopravvissuto alla bonifica, un voto di test su id evento CrAPP orfano è
stato rimosso.
**Aggiornamento (9 settembre 2026)** — la verifica manuale di M15 non lasciava nessuna rete di
sicurezza automatica per il futuro, a differenza del resto del progetto (DD-020). Migration
`m16_funzione_bonifica_dati_evento_orfani` rende lo stesso corpo una funzione
`bonifica_dati_evento_orfani()` (riservata al `service_role`, non richiamabile dall'app),
coperta da `test/integration/bonifica-evento.test.ts`: inserisce una riga orfana e una storica
su id Scout/CSI, richiama la funzione via RPC e verifica che tocchi solo la prima. Non serve
richiamarla di nuovo ora (M15 ha già ripulito lo storico): resta pronta come intervento di
manutenzione se in futuro il trigger di M14 smettesse di funzionare o emergesse un altro batch
di orfani.
**Riesame**
Se una nuova tabella collegata a un evento non viene aggiunta al trigger quando creata (va
aggiornata a mano, non c'è un meccanismo che lo forzi).
-71
View File
@@ -1,71 +0,0 @@
# DD-030 — Centro notifiche in-app: storico separato dalla push, letto solo con RLS
**Data:** 15 settembre 2026
**Stato:** Accettata
**Contesto**
Le notifiche esistenti (`docs/modules/notifiche.md`) sono solo push OS/browser: arrivano se
il dispositivo ha attivato l'interruttore in Profilo, sono effimere (nessuno storico) e non
hanno uno stato letto/non letto — chi non attiva le push, o cambia dispositivo, semplicemente
non le vede mai. Serviva un canale visibile sempre, dentro l'app, con un badge accanto al
profilo che segnali quante cose non lette ci sono.
**Decisione**
Nuova tabella `notifiche_utente` (migration `m17_notifiche_utente`), popolata da quattro
sorgenti lato database — mai dal client:
1. messaggio libero dell'admin (`notifica-personalizzata.ts`, in parallelo alla push);
2. promemoria automatico prima di un evento, 24h e 3h prima, due `cron.schedule`
(estensione `pg_cron`, abilitata dal 31/07/2026 e usata per la prima volta qui);
3. turno palloni (`promemoria-palloni.ts`), in parallelo alla push;
4. sollecito presenze (`sollecita-presenze.ts`), in parallelo alla push.
Lettura e scrittura (segna come letta, elimina) passano dal client Supabase autenticato con
RLS (`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()` in
`src/lib/notifiche-utente.ts`), senza una route API dedicata — stesso pattern di
`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.
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.
**Alternative scartate**
- Notificare anche sulla sola creazione di un evento, non solo sull'avvicinarsi della data →
scartata su richiesta esplicita: conta quando l'evento sta per succedere, non il momento in
cui è stato messo in calendario. La prima versione aveva un trigger `AFTER INSERT ON
eventi_app` per questo, rimosso prima del rilascio.
- Una route API dedicata per leggere/segnare come lette (come le route push esistenti) →
scartata: qui non serve la service role (niente da nascondere al giocatore stesso), quindi
la RLS basta ed evita di duplicare la logica di lettura già scritta lato client per gli
eventi.
- Pulizia automatica delle notifiche vecchie con un cron di retention (es. elimina le lette
dopo 30 giorni) → scartata per ora a favore dello swipe manuale: più diretto da capire per
chi usa l'app, e la tabella resta comunque piccola per una sola squadra. Resta un'estensione
naturale se in futuro servisse.
- Semplice `INSERT` invece di `upsert` per turno palloni e sollecito presenze → scartata: un
admin che preme due volte lo stesso pulsante per lo stesso evento avrebbe urtato contro il
vincolo `UNIQUE (giocatore_id, evento_id, tipo)`. L'upsert aggiorna la riga esistente
(testo e `creato_il` freschi, `letta` di nuovo `false`), così ripremere il pulsante si
comporta come un rinvio vero, non un errore silenzioso.
**Conseguenze**
- 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 (`<input type="time">` si può
svuotare): la funzione `genera_promemoria_eventi()` usa `ora_evento_a_time()`, che assorbe
l'errore di cast riga per riga invece di far fallire l'intera generazione dei promemoria per
tutti gli eventi quando uno solo ha l'ora scritta male.
- `test/integration/notifiche-utente.test.ts` è la definizione eseguibile del comportamento:
promemoria 24h/3h e deduplica, tolleranza a un'ora malformata, RLS su lettura/segna
letta/elimina, e le tre route (`notifica-personalizzata`, `promemoria-palloni`,
`sollecita-presenze`) che scrivono lo storico in-app.
**Riesame**
Se la tabella crescesse in modo sensibile (squadre molto più numerose, o giocatori che non
fanno mai swipe), va aggiunta la pulizia automatica scartata sopra.
-90
View File
@@ -1,90 +0,0 @@
# DD-031 — Data di nascita pubblica: una colonna sincronizzata, non una seconda RLS aperta
**Data:** 15 settembre 2026
**Stato:** Accettata
**Contesto**
In "Squadra" la data di nascita mostrava **"Invalid Date"** per chi l'aveva inserita da
solo dal proprio Profilo. Causa: due fonti mai collegate. I 17 giocatori del seed storico
hanno la nascita in `nascitaPerId`, una mappa statica pubblica bundlata nel client
(`src/lib/crapp-data.ts`) — il commento sulla riga la descriveva esplicitamente come
stopgap: _"`giocatori_squadra` non ha ancora questa colonna (DD-015 follow-up)"_. Un
giocatore aggiunto dopo la migrazione DD-015 inserisce invece la nascita dal form Profilo,
che scrive su `profili_giocatore.data_nascita` — tabella con RLS "solo il proprio profilo o
admin" (M2), mai letta da `useRosa()`/`useAnagraficaRosa()` (`src/lib/rosa.ts`), e comunque
non visibile ai compagni per via della RLS. Per quel giocatore `nascitaPerId[g.id]` era
`undefined` → `""` → `formatData("")` in `src/routes/squadra.tsx` produceva `Invalid Date`.
**Decisione**
Migration `m18_nascita_pubblica_giocatori_squadra`: nuova colonna `giocatori_squadra.nascita`
(quella prevista da DD-015), pubblica a tutta la rosa attiva con la stessa policy di lettura
già in vigore per le altre colonne. Un trigger `SECURITY DEFINER` su `profili_giocatore`
(`sincronizza_nascita_pubblica`, AFTER INSERT/UPDATE/DELETE) la tiene allineata a
`data_nascita`: un solo input nel form Profilo (`CampiProfilo`), due destinazioni — quella
amministrativa esistente (RLS invariata: sé stesso o admin) e questo specchio pubblico.
`profili_giocatore` resta l'unica tabella che il client scrive per la nascita; il client non
tocca mai `giocatori_squadra.nascita` direttamente.
Essendo `SECURITY DEFINER` di proprietà dell'owner della tabella, la scrittura del trigger
bypassa la RLS di `giocatori_squadra` automaticamente — nessuna nuova policy lì. Serve però
un nuovo ramo in `enforce_giocatori_squadra_update()` (il trigger `BEFORE UPDATE` che oggi
rifiuta qualunque modifica a uno slot già collegato da parte di chi non è admin): accetta un
update dove cambia **solo** `nascita` e il chiamante è il proprietario dello slot
(`OLD.auth_user_id = auth.uid()`). Senza quel ramo l'update annidato del trigger di sync
fallirebbe con l'eccezione già in vigore.
Backfill una tantum dei 17 storici con gli stessi valori di `nascitaPerId`, poi rimozione di
quell'export (`crapp-data.ts`): era descritto fin dall'inizio come temporaneo, in attesa
esattamente di questa colonna.
**Alternative scartate**
- Allentare la RLS di `profili_giocatore` per rendere `data_nascita` leggibile a tutta la
squadra → scartata: quella tabella porta anche indirizzo, telefono, documento d'identità
nello stesso record — aprirne la lettura avrebbe esposto molto più della sola nascita.
- Un secondo campo "data di nascita pubblica" nel form Profilo, distinto da quello
amministrativo → scartata: due campi per lo stesso dato reale avrebbero richiesto
spiegare all'utente perché ne esistono due, con rischio di disallineamento se ne compila
uno solo.
- Far scrivere `giocatori_squadra.nascita` direttamente dal client (nuova RLS policy per il
proprietario dello slot) invece di sincronizzarla da `profili_giocatore` → scartata: la
RLS di update per un non-admin resta `USING (auth_user_id IS NULL)` (solo reclamare uno
slot libero); aprirla anche a righe già collegate avrebbe ampliato la superficie scrivibile
dal client su una tabella con un trigger di sicurezza già delicato (DD-016/DD-023), per un
guadagno che il trigger di sync ottiene comunque senza toccare la RLS.
**Conseguenze**
- Chiunque compili la propria data di nascita dal Profilo la vede comparire in Squadra (e
nei compleanni del Calendario) esattamente come i 17 storici — non più un dato "di serie
B".
- Cancellare il profilo (da sé o da un admin) azzera anche la nascita pubblica: non resta un
valore pubblico orfano di un profilo che non esiste più.
- Un giocatore senza nascita nota (profilo mai compilato) non genera più "Invalid Date":
`squadra.tsx` mostra la riga della data di nascita solo se `g.nascita` è valorizzata,
stesso criterio già usato da `compleanniEventi()` per escludere chi non ha ancora un dato.
- `test/integration/nascita-pubblica.test.ts` è la definizione eseguibile del comportamento:
sync alla scrittura, visibilità a un giocatore terzo (non solo l'interessato o l'admin),
azzeramento alla cancellazione, blocco della scrittura diretta dal client.
**Riesame**
Se in futuro serve differenziare "chi può vedere la nascita" (es. solo squadra, non
pubblico) va rivista insieme alla policy di lettura generale di `giocatori_squadra`, non solo
per questa colonna.
**Aggiornamento (15 settembre 2026, stesso giorno)** — dopo aver applicato M18 in produzione
(`supabase db push`), la nascita in Squadra compariva solo per i 17 giocatori storici, non
per chi aveva già compilato il proprio profilo prima della migration: il trigger di sync
copre solo le scritture _future_ su `profili_giocatore`, non retroattivamente, e M18
backfillava a mano solo il seed storico. Migration `m19_backfill_nascita_da_profili_esistenti`
allinea una tantum `giocatori_squadra.nascita` a `profili_giocatore.data_nascita` per tutti i
profili già esistenti, con la stessa disattivazione temporanea del trigger di sicurezza usata
in M18. Da qui in poi il gap non si può ripresentare: ogni scrittura successiva passa dal
trigger di sync, questo backfill serve solo per lo storico antecedente a M18.
A differenza della bonifica eventi orfani (DD-029, M15→M16), qui il backfill non è stato
reso una funzione richiamabile: resta SQL diretto nella migration. La copertura di test
(`test/integration/backfill-nascita-esistenti.test.ts`) verifica comunque lo stesso
comportamento eseguendo lo stesso pattern SQL via `psql` nel container Postgres locale —
scelta necessaria perché senza una funzione RPC, PostgREST non espone un update
multi-tabella come questo.
-40
View File
@@ -1,40 +0,0 @@
# DD-032 — La stagione CSI si ricava dalle date delle gare
**Data:** 24 settembre 2026
**Stato:** Accettata
**Contesto**
La pagina Campionato (`/classifica`) non diceva a quale stagione si riferissero classifica e
storico partite. Il portale CSI non espone il nome della stagione da nessuna parte:
`project-sheets.php`, `league_details.php`, `team-main.php` e il feed
`getEventsByTeamId.php` riportano solo il nome della competizione (verificato il
24/09/2026). Il `project_id` cambia a ogni stagione ed è aggiornato a mano in
`csi-core.ts`.
**Decisione**
`stagioneDi()` in `src/lib/csi-core.ts` ricava la stagione ("2025/26") dalla data di una
gara, con l'anno che cambia ad agosto; `stagioneDaPartite()` usa la prima gara del
campionato letto (tutte le gare, non solo quelle giocate, così a inizio stagione basta il
calendario). L'header di `/classifica` mostra quella stagione, e lo storico partite è
diviso per stagione con `raggruppaPerStagione()`: le gare dello Scout Live locale, usate
quando il CSI non ne ha, possono essere di stagioni diverse.
**Alternative scartate**
- Una costante `CSI_STAGIONE` accanto a `CSI_PROJECT_ID` → un secondo valore da ricordarsi
di aggiornare a ogni stagione, che può divergere dal `project_id`; ricavata dai dati
segue da sola.
- Usare la stagione "in corso" per la squadra (la data di oggi) → sbaglierebbe proprio nel
periodo di passaggio: a settembre 2026 il CSI mostra ancora i dati 2025/26.
**Conseguenze**
- Oggi Campionato mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la
stagione scritta nel codice, mostrano «Stagione 2026/27»: il CSI non ha ancora creato le
pagine della nuova stagione. È voluto — Campionato riporta la stagione dei dati che
mostra (dettagli in [collegamento-csi.md](../modules/collegamento-csi.md)).
- Quando si aggiornano i `project_id` l'etichetta si aggiorna senza toccare altro codice.
**Riesame**
Se il CSI iniziasse a esporre il nome della stagione, leggerlo da lì; se una stagione CSI
dovesse iniziare prima di agosto, spostare la soglia in `stagioneDi()`.
-35
View File
@@ -1,35 +0,0 @@
# DD-033 — Gestione eventi: solo calendario, giorno fissato dal tocco
**Data:** 24 settembre 2026
**Stato:** Accettata
**Contesto**
`/eventi` mostrava sotto la griglia mensile anche la lista cronologica di tutti gli eventi
("Eventi in calendario"). La lista non aveva azioni proprie (toccare una riga apriva lo
stesso drawer del giorno) e allungava la pagina con informazioni già nel calendario. Nel
form, poi, il campo «Data» era già precompilato con il giorno toccato ma sembrava da
compilare: l'amministratore doveva scegliere solo l'ora.
**Decisione**
La pagina mostra solo il calendario, che si scorre di mese in mese con le frecce o con lo
swipe (stessa fisica a molla di `/calendario`, disattivata con `useMotoRidotto()`). Nel form
il giorno compare come testo fisso e resta da scegliere l'ora; il campo data compare solo
toccando «Cambia», per spostare un evento in un altro giorno.
**Alternative scartate**
- Togliere del tutto il campo data → non si potrebbe più spostare un evento esistente, una
funzione che oggi c'è (regola anti-regressione).
- Tenere la lista ma più corta (solo i prossimi eventi) → i prossimi eventi sono già in home
e in `/calendario`; qui sarebbe stata un doppione.
**Conseguenze**
- Per trovare un evento lontano bisogna scorrere fino al suo mese; il totale degli eventi
resta nel sottotitolo dell'header.
- Caricamento ed errore di `useEventi()` compaiono dentro la card del calendario, dove prima
stava la lista.
**Riesame**
Se gli eventi crescono tanto da rendere scomodo lo scorrere dei mesi, valutare una ricerca
per titolo invece di reintrodurre la lista completa.
-51
View File
@@ -1,51 +0,0 @@
# DD-034 — L'allenatore è uno slot della squadra con tipo diverso, non un giocatore
**Data:** 24 settembre 2026
**Stato:** Accettata (implementata con le migration M20 e M21)
**Contesto**
Serve un account per l'allenatore: deve gestire gli eventi (oggi solo admin, `m11`) ma non è
un giocatore. Non ha statistiche, badge né stagione, non vota e non viene votato. L'admin
vuole registrarlo come i giocatori, inserendo la sua email Gmail. Specifica completa in
[modules/allenatore.md](../modules/allenatore.md).
**Decisione**
L'allenatore è una riga di `giocatori_squadra` con una nuova colonna `tipo = 'allenatore'`
(default `'giocatore'`). Così riusa senza cambiarli il collegamento per email (DD-018),
`profili_giocatore` per i dati personali, il bucket avatar e il compleanno sincronizzato
(DD-031). Il permesso di gestire gli eventi arriva da un nuovo valore `allenatore` di
`app_role` in `user_roles` (DD-011), scritto da una funzione `SECURITY DEFINER` quando si
collega uno slot di tipo allenatore: è quindi l'admin a concederlo, registrando lo slot.
"Rosa" diventa `attivo e tipo = 'giocatore'` ovunque.
**Alternative scartate**
- Una tabella `staff` separata → isola l'allenatore da voti e rosa senza toccare i filtri, ma
duplica collegamento per email, profilo, avatar e form admin. Resta l'alternativa se i
filtri sulla rosa si rivelano troppi o fragili.
- Registrare l'allenatore come admin → gli darebbe dati personali e documenti di tutta la
squadra (DD-017), molto più del necessario.
- Distinguere l'allenatore dal campo `ruolo` (ruolo in campo) → mescola due concetti e il
campo è testo libero.
**Conseguenze**
- Ogni lettura della rosa (client, server, SQL di voto e promemoria) va filtrata per `tipo`:
un punto dimenticato rende l'allenatore votabile o lo conta tra i convocati. L'elenco sta
nella specifica e va coperto dai test di `permessi.test.ts`.
- Il trigger sugli slot cambia: `tipo` lo scrive solo l'admin; nome e cognome li scrive anche
l'allenatore sul proprio slot.
- `numero` diventa nullabile a database, ma solo per gli allenatori (vincolo di M21); nell'app
resta un numero, 0 per l'allenatore, per non toccare i tipi di tutta la rosa.
- Due fonti distinte, ciascuna per il suo scopo: i permessi (eventi, sollecito) da
`user_roles`, la visibilità (badge, cacche, stagione) dal tipo del proprio slot, che è
immediato e non aspetta la query dei ruoli.
- L'allenatore non vede le tab Stagione e Badge del Profilo, né Obiettivi e Badge di Squadra.
- In Squadra compare nella tab Rosa con «Allenatore» al posto del ruolo, ricavato da `tipo`;
riceve i promemoria degli eventi (dal 29/09/2026 non più: DD-039) e può sollecitare le presenze,
non il turno palloni.
- Non vede badge né classifiche o sondaggi sulle cacche, in nessuna schermata.
**Riesame**
Se arriva un secondo tipo di staff (dirigente, segreteria) o se i filtri sulla rosa diventano
fonte di bug, passare alla tabella `staff` separata.
-58
View File
@@ -1,58 +0,0 @@
# DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin
**Data:** 24 settembre 2026
**Stato:** Accettata (implementata, versione 1.1.0). La soglia del giocatore è stata alzata a 30 giorni da [DD-041](DD-041.md); quella dello staff resta 7. [DD-046](DD-046.md) aggiunge l'avviso per chi non ha caricato il certificato.
**Contesto**
Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma
oggi ci si accorge che è scaduto solo aprendo la tab Profili di `/admin`: spesso quando il
giocatore è già fuori regola. La richiesta è un avviso in Home, giallo nei 7 giorni prima
della scadenza e nero dopo, con i nomi dei giocatori, che sparisca da solo quando il
certificato viene rinnovato. Specifica completa in
[modules/profilo-giocatore.md](../modules/profilo-giocatore.md#avviso-certificati).
**Decisione**
1. L'avviso si **calcola al volo** dalla data già salvata, con una funzione pura in
`profili-core.ts`: nessuna tabella, nessuno stato «avvisato», nessuna migration.
2. Lo vedono **solo gli admin**, con i nomi di tutta la rosa, e **il giocatore interessato**,
riferito solo a sé. L'allenatore e i compagni non lo vedono.
3. L'avviso dello staff **non è cliccabile**; quello personale apre
`/profilo?tab=documenti`.
4. Soglia fissa di **7 giorni** in una costante; il giorno della scadenza il certificato vale
ancora (come `statoScadenza()`).
5. **Niente push**: l'avviso vive solo in Home.
**Alternative scartate**
- Mostrarlo anche all'allenatore → il certificato è un dato sanitario che oggi l'allenatore
non vede (DD-034); servirebbe una funzione `SECURITY DEFINER` apposta per un'informazione
che all'allenatore non serve ad agire.
- Salvare gli avvisi in una tabella o nel centro notifiche (DD-030) → uno stato da tenere
allineato alla data, che si disallinea appena il giocatore corregge la scadenza; calcolarlo
è gratis e sempre giusto.
- Avviso dello staff cliccabile verso `/admin?tab=profili` → l'admin non può caricare il file
al posto del giocatore (DD-017), quindi il tocco non gli farebbe fare nulla in più, e
richiederebbe di aggiungere a `/admin` la lettura della tab dall'URL.
- Mostrare al massimo alcuni nomi e un «e altri N» → a inizio stagione ne scadono molti
insieme ed è proprio lì che l'elenco completo serve.
- Push quotidiana a chi ha il certificato in scadenza → servirebbe un cron, che oggi non
esiste per nessuna push (DD-025), e le push vanno riservate agli eventi importanti
([EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)).
- Includere i certificati mancanti → è un problema diverso (profilo incompleto), già coperto
dal widget «Completa il tuo profilo» e dalla tab Profili. _Superata da [DD-046](DD-046.md):
anche i mancanti hanno un avviso, rosso._
**Conseguenze**
- Nessuna query nuova: si riusa `useProfili()`, già in cache per Profilo e `/admin`, e le RLS
esistenti decidono quanti profili arrivano (tutti all'admin, il proprio al giocatore).
- Il rinnovo non richiede nessuna azione di chiusura: cambiata la data, l'avviso si aggiorna
da solo.
- Chi non apre l'app non viene avvisato: l'avviso raggiunge solo chi entra in Home.
- ~~Un admin che è anche giocatore vede solo l'avviso dello staff~~ → superato da DD-041: vede anche il proprio avviso personale.
**Riesame**
Se gli avvisi in Home non bastano (certificati che scadono comunque senza essere rinnovati),
valutare una push, legata a un cron o a un invio manuale dell'admin come per i palloni. Se 7
giorni si rivelano pochi per prenotare una visita, alzare la costante o renderla regolabile.
-76
View File
@@ -1,76 +0,0 @@
# DD-036 — Notifiche email via Gmail, da un worker Docker sull'host di casa
**Data:** 29 settembre 2026
**Stato:** Accettata
**Contesto**
Le notifiche di CrAPP arrivano solo con la push (che dipende da Chrome vivo in background,
vedi i limiti in `docs/modules/notifiche.md`) e nel centro notifiche in-app, che si vede solo
aprendo l'app. Chi non ha attivato la push o ha un telefono aggressivo sul risparmio
energetico non riceve nulla ad app chiusa. Serve un canale che arrivi comunque, **gratis**, ai
giocatori registrati, che sono tutti su Gmail perché l'accesso è con Google. L'app gira su
Vercel, che non esegue container né processi sempre attivi.
**Decisione**
Le email partono da un **worker Docker sull'host di casa** che legge una coda su Supabase e
invia tramite `smtp.gmail.com` con un account Gmail dedicato e una password per app.
- La coda è `notifiche_email_coda`, riempita da un trigger su `notifiche_utente`: ogni
notifica già esistente, di qualunque tipo e per qualunque destinatario (admin compresi,
con la stessa logica degli altri), diventa anche una mail. Le sorgenti di M17 non cambiano,
non ne nascono di nuove e l'app non conosce le email. Il certificato in scadenza resta
fuori: è una card in Home, non una notifica (DD-035).
- Il worker fa solo polling **in uscita** con la service role: nessuna porta aperta sull'host.
- La coda è una tabella a parte, con RLS senza policy, perché la policy `UPDATE` di
`notifiche_utente` lascerebbe a un client la possibilità di rimettere in coda una mail.
- Canale **opt-out**: acceso di default per tutti, con un interruttore per account in
Profilo → Opzioni. La preferenza sta in una tabella nuova, `preferenze_utente`, una riga
per account (non per slot); l'assenza di riga vale «acceso».
- Specifica completa in [notifiche-email.md](../modules/notifiche-email.md); installazione e
gestione in [WORKER_EMAIL.md](../WORKER_EMAIL.md).
**Alternative scartate**
- Inviare direttamente dal proprio IP con un MTA (Postfix) → scartata: porta 25 bloccata dai
provider residenziali, IP nelle blocklist, niente reverse DNS; le mail finirebbero in spam o
sarebbero rifiutate.
- Relay Brevo o Resend → scartati per ora: il piano gratuito funziona ma per una buona
consegna richiede un dominio con SPF/DKIM/DMARC, che oggi non c'è. Restano l'alternativa
se servirà un mittente della squadra.
- Supabase Edge Function con `pg_cron` e un relay → scartata su richiesta esplicita di un
servizio dockerizzato sull'host; resta la strada senza host acceso, ma dipende comunque da un
relay esterno.
- Vercel Cron → scartato: sul piano gratuito gira al massimo una volta al giorno, troppo poco
per i promemoria a 24h e 3h.
- Chiamare l'host dall'app (webhook verso casa) → scartato: richiederebbe un IP pubblico o un
tunnel e una superficie d'attacco in più; il polling in uscita non ne ha.
- Colonne di stato sulla riga di `notifiche_utente` → scartate per la RLS spiegata sopra.
**Conseguenze**
- Costo zero, ma il canale email dipende da un host acceso: se è spento le mail arrivano in
ritardo (mai perse). Consegna _almeno una volta_: in caso di crash tra invio e
registrazione può arrivare un doppione.
- Il tetto di circa 500 destinatari al giorno di Gmail è largo per una squadra, ma è un limite
reale: con molte squadre o riepiloghi frequenti andrebbe rivisto.
- Nuova migration (`m22_notifiche_email`), nuova cartella `mailer/` (worker bun con la sola
dipendenza `nodemailer`, separato dall'app) e un segreto in più (la service role sull'host).
L'app non guadagna nessuna dipendenza e le route di M17 non cambiano.
- Il certificato in scadenza resta fuori dalle email: non è una notifica (DD-035), quindi non
passa da `notifiche_utente`. Renderlo una notifica sarebbe una decisione a parte.
- Il tetto giornaliero è una finestra mobile di 24 ore, `MAIL_LIMITE_GIORNO` (400 di default,
sotto i circa 500 di Gmail).
- Le email dei giocatori si usano per un secondo scopo oltre al login: da qui l'opt-out e
l'indicazione in ogni mail su come disattivarlo.
- Una tabella in più, `preferenze_utente`, pensata per accogliere in futuro anche le
preferenze per tipo di notifica senza toccare `giocatori_squadra`.
- In produzione dal 29/09/2026 (migration e worker su un Raspberry Pi). Dalla prova reale: la
prima mail, da un account Gmail nuovo, è arrivata ma nella cartella spam, e la password per app
non era disponibile finché l'account aveva le sole passkey come secondo passaggio (dettagli in
[WORKER_EMAIL.md](../WORKER_EMAIL.md#account-gmail-e-password-per-app)).
**Riesame**
Se serve un mittente col dominio della squadra, se le mail finiscono in spam anche dopo qualche
giorno di uso, se si supera il tetto giornaliero di Gmail, o se l'host di casa si rivela poco
affidabile: passare a un relay con dominio e, in quel caso, valutare Edge Function più `pg_cron`
al posto del container.
-55
View File
@@ -1,55 +0,0 @@
# DD-037 — I promemoria evento ricordano di essere già stati generati
**Data:** 29 settembre 2026
**Stato:** Accettata
**Contesto**
I promemoria a 24 e a 3 ore (M17, DD-030) evitavano i doppioni con `ON CONFLICT DO NOTHING` sul
vincolo `UNIQUE (giocatore_id, evento_id, tipo)` di `notifiche_utente`: la riga della notifica
era l'unica traccia che il promemoria fosse già stato generato. Il giocatore però può eliminarla
(swipe o ×, DD-030). Senza più la riga, al giro successivo del job (ogni ora per il promemoria a
24 ore, ogni 15 minuti per quello a 3 ore, finché l'evento non inizia) il promemoria veniva
rigenerato come non letto. Con il canale email (DD-036) ogni rigenerazione accodava anche una
mail nuova: un giocatore che eliminava un promemoria ne riceveva una ogni ora fino all'inizio
dell'evento. Il difetto è stato trovato controllando la logica il 29/09/2026, il giorno in cui il
worker email è entrato in produzione, e riprodotto sul database locale.
**Decisione**
Un registro separato, `promemoria_eventi_generati` (migration `m23_promemoria_gia_generati`), con
una riga per `(giocatore_id, evento_id, tipo)` scritta dalla funzione `genera_promemoria_eventi()`.
La notifica in-app si crea solo per le righe **appena registrate**, quindi eliminarla non fa
ripartire nulla. Il registro è indipendente da `notifiche_utente`, senza policy per i client, e
sparisce con l'evento o con il giocatore (`ON DELETE CASCADE`). I promemoria già presenti al
momento della migration sono stati registrati come generati, altrimenti la prima notifica
eliminata dopo la migration sarebbe tornata come prima. Il vincolo `UNIQUE` di `notifiche_utente`
resta come seconda difesa. Cron, criteri di selezione, testi e destinatari sono invariati.
**Alternative scartate**
- Non eliminare davvero la notifica (nasconderla con un flag) → scartata: cambia il significato
dell'eliminazione per tutte le sorgenti, tocca la RLS e le query del client, e la tabella
crescerebbe di righe che il giocatore non vede più.
- Togliere l'eliminazione dai soli promemoria, lasciando «segna come letta» → scartata: a un
giocatore che vuole ripulire l'elenco resterebbero notifiche non eliminabili, incoerenti con
le altre.
- Un flag «già generato» sulla riga dell'evento → scartata: il promemoria è per giocatore, e chi
viene aggiunto ai convocati dopo deve comunque riceverlo.
- Confrontare `creato_il` o lo stato «letta» per capire se ricrearlo → scartata: non distingue
una notifica mai generata da una eliminata.
**Conseguenze**
- Eliminare un promemoria lo fa sparire per sempre, e non parte nessuna mail in più. Il numero
massimo di mail automatiche per giocatore ed evento resta due (24 e 3 ore).
- Una tabella in più, che cresce di una riga per giocatore, evento e tipo e si svuota con
l'evento.
- Se un evento viene spostato dopo che il promemoria è partito, non ne parte uno nuovo per la
data nuova: è lo stesso comportamento di prima, ora esplicito.
- `test/integration/promemoria-eventi.test.ts` è la definizione eseguibile: il promemoria
eliminato non ricompare, non riaccoda mail, i due tipi sono indipendenti, un convocato aggiunto
dopo lo riceve comunque, il registro non è leggibile né scrivibile da un client e sparisce con
l'evento. Sul database con la funzione vecchia il test fallisce.
**Riesame**
Se gli eventi potessero essere spostati con frequenza e servisse un nuovo promemoria per la data
nuova, il registro va agganciato anche alla data di inizio, oltre a giocatore, evento e tipo.
-68
View File
@@ -1,68 +0,0 @@
# DD-038 — Ogni notifica ha tre canali: la push dei promemoria parte dal worker
**Data:** 29 settembre 2026
**Stato:** Accettata
**Contesto**
Il requisito è che ogni notifica arrivi su tutti e tre i canali: push, centro notifiche in-app ed
email. Due casi non lo rispettavano ([catalogo](../modules/notifiche.md#catalogo-delle-notifiche)):
- i **promemoria a 24 e 3 ore** nascono dentro il database (job `pg_cron`, M17), che non può
firmare né cifrare una push Web (VAPID, RFC 8291): arrivavano solo in-app e per email;
- il **sondaggio pre-partita** partiva solo come push e non scriveva in `notifiche_utente`, quindi
non compariva in-app e, dato che le email nascono da quella tabella (DD-036), non arrivava per
email.
Le altre notifiche (messaggio dello staff, turno palloni, sollecito presenze) partono dall'app su
Vercel e avevano già i tre canali.
**Decisione**
1. **Sondaggio pre-partita:** la route `apri-sondaggio` scrive anche `notifiche_utente` (tipo
nuovo `sondaggio_cacche`, un `upsert` per giocatore ed evento come le altre), per i giocatori
attivi che non sono allenatori. La mail parte dal trigger di M22 senza altro lavoro. La push
resta com'è. Il vincolo `CHECK` sul tipo si estende con la migration `m24`.
2. **Push dei promemoria:** la manda il **worker `mailer/`**, con un secondo canale accanto alle
email. Un trigger accoda ogni promemoria a 24 o 3 ore in `notifiche_push_coda` (stessa
struttura di `notifiche_email_coda`); il worker prende il lotto con `FOR UPDATE SKIP LOCKED`,
manda la push a tutti i dispositivi iscritti del giocatore e registra l'esito. Usa **lo stesso
modulo dell'app** (`src/lib/webpush.server.ts`, senza dipendenze) invece di una copia: il
container si costruisce dalla radice del repository. Le iscrizioni che rispondono 404 o 410
vengono eliminate, come nelle route. Le push in coda da oltre 3 ore si scartano: un promemoria
arrivato a evento iniziato è solo rumore.
3. La push dei promemoria non dipende dall'interruttore «Email»: dipende solo dai dispositivi
iscritti, come le altre push.
**Alternative scartate**
- Far chiamare all'app dal database (`pg_net`, già abilitata) una route che manda la push →
scartata: il database dipenderebbe dall'indirizzo pubblico di Vercel e da un segreto condiviso,
e `pg_net` è un'estensione del provider (regola 4 di [PORTABILITA.md](../PORTABILITA.md)).
- Vercel Cron → scartato: sul piano gratuito gira una volta al giorno, come già notato in DD-036.
- Copiare il modulo push nel worker → scartata: due versioni da tenere allineate per codice che
cifra e firma.
- Mandare dal worker la push di tutte le notifiche → scartata: le altre partono già dall'app, si
avrebbero push doppie.
- Un unico stato di coda per email e push → scartata: le due consegne hanno esiti, tentativi e
destinatari diversi (una push per dispositivo, un'email per giocatore).
**Conseguenze**
- Il worker non fa più solo email: gestisce due canali (il documento operativo resta
[WORKER_EMAIL.md](../WORKER_EMAIL.md)). Serve la chiave VAPID privata anche sull'host, un segreto in
più. Senza le chiavi il worker parte lo stesso e manda solo le email, segnalandolo nel log.
- La push dei promemoria dipende dall'host di casa acceso, come le email: se è spento parte alla
ripartenza, ma non oltre le 3 ore dalla creazione.
- Una tabella nuova (`notifiche_push_coda`) e una migration (`m24_push_promemoria_e_sondaggio`).
- Il sondaggio compare nel centro notifiche e arriva per email ai giocatori attivi, non agli
allenatori; la push continua ad andare a tutti i dispositivi iscritti tranne quelli degli
allenatori, anche di giocatori non più attivi.
- Le altre notifiche non cambiano canali e non ricevono push doppie.
**Aggiornamento (30 settembre 2026)** — DD-040 esclude dalle email le notifiche del turno palloni: per
quei tipi i canali sono due (push e in-app).
**Riesame**
Se l'host di casa si rivela poco affidabile per una notifica sensibile al tempo come la push, o se
il database dovesse comunque chiamare l'app per altro, valutare di nuovo la chiamata dal database
all'app.
-40
View File
@@ -1,40 +0,0 @@
# DD-039 — L'allenatore non riceve i promemoria degli eventi
**Data:** 29 settembre 2026
**Stato:** Accettata
**Contesto**
DD-034 stabilì che l'allenatore riceve i promemoria a 24 e 3 ore prima di un evento, sempre, convocato
o no (`giocatori_destinatari_evento()` riscritta da M21). Ma un promemoria serve a ricordare di
esserci, e l'allenatore non può rispondere alle presenze, non è convocabile e non compare tra i
partecipanti (DD-034). L'incoerenza si vede negli eventi extra-campo, come una cena di squadra:
riceveva l'avviso (in-app, per email e, dopo M24, come push) senza poter dire se ci sarebbe stato né
comparire nell'elenco.
**Decisione**
I destinatari dei promemoria sono i **giocatori attivi**: i convocati, o tutta la rosa se
`convocati` è vuoto. L'allenatore non li riceve, per nessun tipo di evento. Cambia solo la funzione
SQL (migration `m25_promemoria_solo_giocatori`, `g.tipo = 'giocatore'` al posto di «o allenatore
sempre»); cron, criteri di selezione, registro dei promemoria (DD-037), code email e push e testi
restano invariati.
**Alternative scartate**
- Toglierli solo per gli eventi extra-campo → scartata su indicazione esplicita di toglierli per
gli eventi in generale: anche per un allenamento o una partita l'allenatore non è tra i
partecipanti.
- Farlo rispondere agli eventi extra-campo, così da tenere il promemoria → scartata: servono
risposte presenze e regole del database per una categoria nuova, e il conteggio dei presenti.
**Conseguenze**
- Le notifiche che l'allenatore riceve dal server sono i **messaggi dello staff** e nient'altro
(sollecito, turno palloni, sondaggio e notifiche smart non lo riguardano già).
- I promemoria già generati per un allenatore prima della migration non si toccano.
- Un allenatore non è avvisato dell'inizio di un evento che gestisce: sa quando è perché lo ha
creato lui o lo vede in Calendario.
- Sostituisce la parte di DD-034 sui promemoria; il resto di DD-034 resta valido.
**Riesame**
Se l'allenatore dovesse poter partecipare a certi eventi (per esempio la cena), si riapre insieme
alla sua possibilità di rispondere.
-94
View File
@@ -1,94 +0,0 @@
# DD-040 — Promemoria con «oggi» o «domani», turno palloni e solleciti automatici
**Data:** 30 settembre 2026
**Stato:** Accettata (implementata: migration `m26`, applicata in produzione il 30 settembre 2026, job, route e test). Il sollecito presenze è poi passato a un solo avviso a 24 ore da [DD-045](DD-045.md). Il turno palloni è poi passato a un solo avviso a 3 ore da [DD-042](DD-042.md).
**Contesto**
Quattro notifiche su sei partivano da un pulsante premuto da un admin (DD-025 per i palloni): se nessuno
lo premeva, nessuno veniva avvisato. I testi erano inoltre brevi e informali, e i promemoria non dicevano
quando e dove. Si vuole che turno palloni e sollecito presenze partano anche da soli, a orari fissi prima
dell'evento, e che i testi siano più formali e completi. I testi nuovi sono nel
[catalogo](../modules/notifiche.md#testi-delle-notifiche).
**Decisione**
1. **Promemoria a 24 e 3 ore:** nuovo testo con data, ora e luogo. La parola nel titolo e nella riga
«Data» è **«domani»** se la data dell'evento è quella di domani (fuso `Europe/Rome`), **«oggi»**
altrimenti. Vale per entrambi i promemoria, quindi anche quello a 3 ore di un evento dopo la
mezzanotte dice «domani». La parola la calcola la funzione SQL quando genera la notifica.
2. **Turno palloni automatico.** Un job `pg_cron` ogni 15 minuti avvisa per gli eventi di tipo allenamento
o partita, in tre tempi con finestre che non si sovrappongono: **12 ore** (tra 12 e 6 ore prima
dell'inizio), **6 ore** (tra 6 e 3) e **3 ore** (meno di 3). Parte **solo se per l'evento c'è un
incaricato confermato** in `turni_palloni`: le proposte automatiche di `completaTurni()` non salvate non
contano. Gli avvisi sono due, con lo stesso testo nei tre tempi: «incarico assegnato» all'incaricato e
«riconsegna» a chi aveva i palloni all'evento precedente, se è un'altra persona.
- **Pulsante manuale.** «Avvisa chi è di turno» e la route `promemoria-palloni` **restano**: tipo
`turno_palloni`, testo nuovo, push mandata dalla route e non dalla coda. È una notifica in più;
riceverne due (automatica e manuale) va bene.
- **Incaricato cambiato dopo un avviso.** Il nuovo incaricato riceve il suo avviso al giro successivo,
con il tipo della finestra in cui cade. Chi aveva già ricevuto un avviso e non è più destinatario
riceve **una sola volta** «Turno palloni: incarico revocato» (`turno_palloni_revocato`), se
l'evento non è iniziato. Il job lo scopre confrontando i destinatari attuali con il **registro** degli
avvisi automatici già generati (non con le notifiche, che il giocatore può aver eliminato), senza
trigger su `turni_palloni`. Gli avvisi del pulsante non contano: possono riguardare una proposta non
confermata.
- **Niente email.** Gli avvisi palloni (automatici, revoca e pulsante) arrivano come push e nel centro
notifiche, **non per email**: il trigger `notifiche_utente_accoda_email` salta i tipi
`turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato`. È un'eccezione a
DD-038 («ogni notifica ha tre canali») solo per questi tipi; l'interruttore «Email» non cambia.
3. **Sollecito presenze automatico in tre tempi.** Lo stesso job manda il sollecito **24 ore** (tra 24 e
12 ore prima), **12 ore** (tra 12 e 6) e **6 ore** (meno di 6) prima, al massimo una notifica per
giocatore e per tipo. Lo riceve solo chi in quel momento non ha risposto o ha risposto «forse» ed è
destinatario dell'evento: i convocati, o tutta la rosa attiva se `convocati` è vuoto, mai l'allenatore.
Chi risponde presente, assente o in ritardo smette di riceverli; chi resta su «forse» li riceve tutti e
tre. Un evento creato a meno di 12 ore dall'inizio riceve solo il sollecito della sua finestra.
- **Pulsante manuale.** «Sollecita» e la route `sollecita-presenze` **restano**, anche per l'allenatore:
tipo `sollecita_presenze`, push mandata dalla route, testo nuovo con in più la riga «Richiesta di:
_Nome_». I destinatari sono **gli stessi dell'automatico** (convocati senza risposta o con «forse»);
oggi la route guarda invece tutti i giocatori attivi.
4. **Stessa infrastruttura dei promemoria** (DD-037, DD-038): le notifiche automatiche nascono nel
database, il registro `promemoria_eventi_generati` impedisce i doppioni anche se il giocatore le
elimina, la push la manda il worker dalla coda `notifiche_push_coda`, l'email (quando c'è) nasce dal
trigger su `notifiche_utente`. Tipi nuovi: `sollecita_presenze_24h`, `_12h`, `_6h`;
`turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato`. Il `turno_palloni` del pulsante resta.
5. **Migration `m26`:** estende i `CHECK` sui tipi di `notifiche_utente` e del registro, il trigger della
coda push, il trigger della coda email (che salta i tipi dei palloni) e ridefinisce la funzione dei
promemoria. Le funzioni nuove riusano `giocatori_destinatari_evento()`.
**Alternative scartate**
- Togliere i pulsanti manuali (turno palloni e sollecito) una volta arrivati gli automatici → scartata su
indicazione esplicita: servono all'admin per rimandare un avviso o sollecitare fuori dagli orari fissi.
- Mandare tutti i solleciti (o gli avvisi palloni) a un evento creato tardi → scartata: più messaggi nel
giro di pochi minuti sono rumore.
- «Domani» e «oggi» fissi nei due testi → scartata: il job delle 24 ore scatta anche per eventi che
iniziano oggi, e quello delle 3 ore attraversa la mezzanotte.
- Calcolare il testo nell'app invece che nel database → scartata: le notifiche nascono nei job `pg_cron`,
che non chiamano l'app (DD-038).
- Spostare nel worker il calcolo di evento precedente e successivo dei palloni → scartata: un secondo
punto che scrive in `notifiche_utente`, per un vantaggio piccolo.
- Ritirare l'avviso già consegnato al vecchio incaricato → scartata: per la push non si può, e in-app
confonde. Si manda la revoca.
**Conseguenze**
- Cambia la regola «quattro notifiche su sei dipendono da un gesto manuale»: restano solo manuali il
messaggio dello staff e il sondaggio pre-partita; per turno palloni e sollecito gli automatici si
aggiungono ai pulsanti, e un giocatore può ricevere lo stesso avviso due volte.
- **Logica duplicata:** «evento precedente» e «evento successivo» per i palloni, in `palloni-core.ts`
(usato dal pulsante e dalla schermata), sono riscritti in SQL per i job (`genera_avvisi_palloni_fascia`).
Ordine comune: data, ora, id. Se si cambia una regola in una sola delle due versioni, pulsante e job danno
risposte diverse: `notifiche-automatiche.test.ts` confronta testi e destinatari sugli stessi dati, anche a
parità di data e ora.
- `destinatariSollecito()` riceve anche i convocati e serve al sollecito manuale; un giocatore non
convocato non è più sollecitato. `avvisiPalloniEvento()` resta come riferimento per i test finché il SQL
non la sostituisce del tutto.
- Se un incaricato revocato viene poi riassegnato non riceve un secondo avviso, perché il registro lo ha
già generato per quella finestra: la revoca resta l'ultima notifica che ha letto.
- Le mail automatiche per evento e giocatore sono al massimo **cinque** (due promemoria, tre solleciti),
più quelle dei pulsanti (sollecito, sondaggio). Il turno palloni non ne manda.
- Il testo dei promemoria già generati non cambia (il registro impedisce di rigenerarli).
**Riesame**
Dopo la prima stagione di uso: se i solleciti o gli avvisi palloni risultano troppi, ridurre le
finestre o escludere chi ha risposto «forse» più di una volta.
-39
View File
@@ -1,39 +0,0 @@
# DD-041 — Avviso certificati: il giocatore è avvisato un mese prima dello staff
**Data:** 1 ottobre 2026
**Stato:** Accettata (modifica la soglia di [DD-035](DD-035.md) per il solo giocatore)
**Contesto**
Con DD-035 giocatore e admin vedono l'avviso negli stessi 7 giorni prima della scadenza. Per
il giocatore sono pochi: prenotare una visita di idoneità richiede più tempo (era il rischio
indicato nel Riesame di DD-035). All'admin invece un avviso così anticipato riempirebbe la
Home di nomi che non può ancora sollecitare con urgenza.
**Decisione**
1. Il **giocatore** vede l'avviso giallo da **30 giorni** prima della scadenza
(`GIORNI_AVVISO_CERTIFICATO_GIOCATORE`).
2. L'**admin** resta a **7 giorni** prima (`GIORNI_AVVISO_CERTIFICATO`), invariato.
3. Dopo la scadenza l'avviso è **nero** per entrambi, invariato.
4. L'**admin che è anche giocatore** vede il proprio avviso **personale** (30 giorni,
cliccabile) oltre a quello dello staff, dal quale il suo nome è tolto per non duplicarlo.
5. Nessun altro aspetto di DD-035 cambia: calcolo al volo, niente push, stessi destinatari.
Esempio: scadenza il 5 ottobre 2026 → dal 5 settembre l'avviso giallo compare solo al
giocatore; dal 28 settembre compare anche agli admin; dal 6 ottobre è nero per entrambi.
**Alternative scartate**
- «Un mese» come mese di calendario → la soglia varierebbe tra 28 e 31 giorni; 30 giorni fissi
sono prevedibili e coincidono con l'esempio della richiesta.
- Soglia unica a 30 giorni anche per l'admin → elenco dello staff pieno con settimane di
anticipo, senza nulla da fare per l'admin che non può caricare il file (DD-017).
**Conseguenze**
- Il giocatore vede il conto alla rovescia «scade tra N giorni» con N fino a 30.
- `avvisiCertificati()` riceve la soglia come parametro (default 7, cioè lo staff).
**Riesame**
Se 30 giorni risultano ancora pochi o troppi, cambiare la costante; se serve una soglia
regolabile dall'admin, vedi il Riesame di DD-035.
-36
View File
@@ -1,36 +0,0 @@
# DD-042 — Turno palloni: un solo avviso, 3 ore prima
**Data:** 1 ottobre 2026
**Stato:** Accettata (migration `m27`, applicata in produzione il 1 ottobre 2026; modifica la parte «turno palloni» di [DD-040](DD-040.md))
**Contesto**
DD-040 manda l'avviso del turno palloni a 12, 6 e 3 ore prima dell'evento: tre messaggi identici per
chi deve portare i palloni e per chi deve prenderli. Sono troppi per un'informazione che non cambia.
**Decisione**
1. L'avviso automatico parte **una sola volta, 3 ore prima** dell'inizio (finestra «meno di 3 ore»), a
chi deve portare i palloni («incarico assegnato») e a chi li deve prendere («riconsegna», se è
un'altra persona). Testi, destinatari, push senza email e condizione dell'incaricato confermato
restano quelli di DD-040.
2. La revoca resta: chi è stato avvisato e poi non è più destinatario riceve una sola volta «incarico
revocato». Un evento creato o assegnato a meno di 3 ore dall'inizio riceve l'avviso al giro
successivo del job.
3. **Migration `m27`:** `genera_avvisi_palloni()` chiama solo la fascia `turno_palloni_3h`. I tipi
`turno_palloni_12h` e `_6h` **restano nei `CHECK`** e nella revoca, perché notifiche e righe di
registro già generate li usano; non ne nascono di nuovi.
4. Il pulsante «Avvisa chi è di turno» non cambia. I solleciti presenze non cambiano qui (poi ridotti a uno, DD-045).
**Alternative scartate**
- Togliere i tipi `12h` e `6h` dai `CHECK` → le righe già generate in produzione non sarebbero più
valide e la revoca non vedrebbe più chi ha ricevuto quegli avvisi.
- Tenere anche un avviso a 12 ore → scartata su indicazione esplicita: uno solo.
**Conseguenze**
- Chi non apre l'app nelle 3 ore prima può non vedere l'avviso; la push è il canale principale.
- Per le notifiche palloni già generate con DD-040 non cambia nulla.
**Riesame**
Se 3 ore risultano poche per procurarsi i palloni, alzare la finestra (una sola, mai più avvisi).
-35
View File
@@ -1,35 +0,0 @@
# DD-043 — Niente promemoria automatico a 24 e 3 ore prima degli eventi
**Data:** 1 ottobre 2026
**Stato:** Accettata (migration `m28`, applicata in produzione il 1 ottobre 2026; sostituisce il promemoria di
[DD-030](DD-030.md), [DD-037](DD-037.md) e [DD-040](DD-040.md))
**Contesto**
Il promemoria a 24 e 3 ore prima di ogni evento non serve: la squadra ha già il calendario, il
sollecito presenze (per chi non ha risposto) e l'avviso del turno palloni a 3 ore.
**Decisione**
1. Il database **non genera più** `evento_promemoria_24h` ed `evento_promemoria_3h`, per nessun tipo di
evento (allenamento, partita, extra-campo).
2. La migration `m28` toglie solo i due job `pg_cron` (`promemoria-eventi-24h` e `-3h`).
`genera_promemoria_eventi()`, i tipi nei `CHECK`, il registro `promemoria_eventi_generati` e la coda
push restano: le notifiche già create continuano a esistere e rimettere il promemoria richiede solo
un `cron.schedule`.
3. Non cambiano: sollecito presenze, turno palloni (DD-042), messaggio dello staff, sondaggio.
**Alternative scartate**
- Togliere il promemoria solo per gli allenamenti → scartata su indicazione esplicita: via per tutti.
- Cancellare funzione, tipi e registro → scartata: vanno tenute le notifiche già generate e il lavoro per
rimetterlo sarebbe molto più grande di un `cron.schedule`.
**Conseguenze**
- Meno notifiche per la squadra. Chi voleva il promemoria di una partita non lo riceve più.
- Funzione, test e registro restano come codice non pianificato: sono ancora coperti da
`promemoria-eventi.test.ts` e dagli altri test sulla funzione.
**Riesame**
Se la squadra sente la mancanza del promemoria (per esempio solo per le partite), rimettere i job o
reintrodurlo per tipo di evento.
-38
View File
@@ -1,38 +0,0 @@
# DD-044 — Via la foto tessera dal profilo
**Data:** 1 ottobre 2026
**Stato:** Accettata (migration `m29`, applicata in produzione il 1 ottobre 2026)
**Contesto**
Il profilo amministrativo chiedeva al giocatore una foto formato tessera (DD-016), per il
tesseramento CSI. Non serve più: non viene richiesta nel tesseramento e resta un dato personale
da custodire.
**Decisione**
1. Il profilo **non ha più la foto tessera**: sparisce il campo di caricamento dal Profilo e la
voce «Foto» dalla scheda dell'admin.
2. **Completamento del profilo** su tre sezioni: dati personali 34%, documento 33%, certificato 33%
(prima 30/30/30/10). Il totale resta 100 e il widget «Completa il tuo profilo» sparisce con le
tre sezioni complete.
3. **Dati:** la migration `m29` elimina la colonna `profili_giocatore.foto_path`. I file
`<giocatore_id>/foto.*` del bucket privato `profili-giocatore` vanno cancellati con l'API
Storage (da SQL Storage lo vieta) prima della migration, perché dopo i path non sono più
leggibili. **Non toccati:** l'avatar (bucket `avatar-giocatori`), il documento e il certificato.
4. Ordine in produzione: **prima il rilascio del codice** (la versione precedente seleziona
`foto_path` e darebbe errore), poi cancellazione dei file, poi `m29`.
**Alternative scartate**
- Lasciare la colonna e nascondere solo l'interfaccia → scartata: resterebbe un dato personale
senza uso e senza un campo da cui aggiornarlo.
- Tenere i pesi 30/30/30 senza rinormalizzare → scartata: nessun profilo arriverebbe a 100%.
**Conseguenze**
- Cancellazione definitiva: le foto già caricate non si recuperano.
- Il 1 ottobre 2026 sono state cancellate in produzione le 4 foto caricate, poi la colonna.
- Le percentuali di completamento cambiano per tutti (chi aveva il 90% senza foto passa al 100%).
**Riesame**
Se il comitato CSI dovesse richiedere di nuovo la foto, reintrodurla con una nuova decisione.
-34
View File
@@ -1,34 +0,0 @@
# DD-045 — Sollecito presenze: un solo avviso automatico, 24 ore prima
**Data:** 1 ottobre 2026
**Stato:** Accettata (migration `m30`, applicata in produzione il 1 ottobre 2026; modifica la parte «sollecito presenze»
di [DD-040](DD-040.md))
**Contesto**
DD-040 manda il sollecito presenze a 24, 12 e 6 ore prima dell'evento: fino a tre messaggi per chi non
risponde o resta su «forse». Ne basta uno.
**Decisione**
1. Il sollecito automatico parte **una sola volta, 24 ore prima** (finestra «meno di 24 ore»), a chi in
quel momento non ha risposto o ha risposto «forse» tra i destinatari dell'evento (convocati, o tutta
la rosa attiva; mai l'allenatore). Un evento creato a meno di 24 ore dall'inizio lo riceve comunque,
al giro successivo del job. Testo e canali (push, centro notifiche, email) restano quelli di DD-040.
2. Il **pulsante «Sollecita»** e la route `sollecita-presenze` non cambiano: l'admin o l'allenatore possono
sempre rimandarlo a mano, anche dopo l'automatico.
3. **Migration `m30`:** `genera_solleciti_presenze()` chiama solo la fascia `sollecita_presenze_24h`. I tipi
`sollecita_presenze_12h` e `_6h` restano nei `CHECK` per le notifiche già generate; non ne nascono di nuovi.
**Alternative scartate**
- Mandarlo a un orario più vicino (12 o 6 ore) → scartata su indicazione esplicita: 24 ore.
- Togliere i tipi `12h` e `6h` dai `CHECK` → le righe già generate non sarebbero più valide.
**Conseguenze**
- Chi risponde «forse» o non risponde dopo il sollecito non riceve altri avvisi automatici: tocca al
pulsante manuale.
- Il massimo di mail automatiche per giocatore ed evento scende a una (il sollecito).
**Riesame**
Se il tasso di risposta cala, valutare un secondo sollecito più vicino all'evento, solo per chi non ha mai risposto.
-33
View File
@@ -1,33 +0,0 @@
# DD-046 — Avviso certificati: anche chi non l'ha caricato
**Data:** 4 ottobre 2026
**Stato:** Accettata (modifica [DD-035](DD-035.md): il certificato mancante non è più escluso dall'avviso)
**Contesto**
L'avviso certificati di DD-035 guarda solo la scadenza e salta chi non ha mai caricato il
certificato («è un problema diverso»). Per l'admin significa che chi non ha caricato nulla non
compare in Home, pur essendo un caso più grave di uno scaduto.
**Decisione**
1. Un giocatore in rosa **senza data o senza file** del certificato è **mancante**.
2. L'**admin** vede la card rossa «Certificati mancanti» con l'elenco, **sopra** scaduti e in
scadenza; il suo nome non compare se è anche giocatore.
3. Il **giocatore** mancante vede la card rossa «Certificato mancante», cliccabile verso il tab
documenti. Vale anche per l'admin che è giocatore.
4. Nessuna soglia: l'avviso resta finché il certificato non è caricato. Il rosso è `destructive`,
già nel tema; giallo e nero degli altri avvisi non cambiano.
**Alternative scartate**
- Stesso nero degli scaduti → non distingue «scaduto» da «mai caricato», che è più grave.
- Solo elenco admin → l'admin che è anche giocatore sarebbe l'unico a non vedere il proprio buco.
**Conseguenze**
- `avvisiCertificati()` restituisce anche `mancanti`; `avvisiCertificatiUtente()` anche
`personaleMancante`.
- Un giocatore appena entrato in rosa compare tra i mancanti finché non carica il file.
**Riesame**
Se i giocatori nuovi riempiono l'elenco senza motivo, aggiungere un periodo di tolleranza.
-65
View File
@@ -1,65 +0,0 @@
# DD-047 — Notifica di compleanno
**Data:** 6 ottobre 2026
**Stato:** Accettata (migration `m31`, non ancora applicata in produzione; nasce dalla issue #11 «notifica»)
**Contesto**
I compleanni si vedono solo aprendo il Calendario, dove `compleanniEventi()` li calcola dalla data di
nascita pubblica (`giocatori_squadra.nascita`, DD-031). Nessun avviso arriva ad app chiusa. La issue #11
chiede una notifica del tipo «oggi è il compleanno di _tizio_».
**Decisione**
1. **Una notifica vera, automatica, su tutti e tre i canali** (DD-038): push, centro notifiche in-app ed
email. Nasce nel database come le altre notifiche automatiche (DD-040): un job `pg_cron` scrive in
`notifiche_utente`, il worker `mailer/` manda push ed email.
2. **Quando:** una volta al giorno, **dalle 8:00 (fuso `Europe/Rome`)**. Il job gira ogni ora e agisce solo
dalle 8 in poi; un registro ricorda a chi l'avviso del giorno è già stato generato, quindi un giro
mancato si recupera all'ora dopo e non si genera mai due volte.
3. **Chi compie gli anni** è chi ha `nascita` con lo stesso giorno e mese di oggi tra i membri **attivi**
della squadra (`giocatori_squadra.attivo`), allenatori compresi. Senza data di nascita o non attivo:
nessun avviso.
4. **Destinatari: tutti i membri attivi**, allenatori compresi (il compleanno è un fatto della squadra,
non una notifica di gioco: DD-039 non si applica). Due messaggi diversi, entrambi **senza l'età**:
- **il festeggiato** riceve **solo gli auguri**: titolo `Buon compleanno, <Nome>! 🎂`, **senza corpo**;
- **tutti gli altri** ricevono **solo l'avviso**: titolo `Oggi è il compleanno di <Nome>`, **senza corpo**.
5. **Più compleanni lo stesso giorno: un solo messaggio per destinatario.** Chi non compie gli anni riceve
un unico avviso con tutti i nomi: titolo `Oggi è il compleanno di <A> e <B>` (con tre o più: `A, B e C`,
in ordine alfabetico per nome), sempre senza corpo. Ogni festeggiato riceve **solo i propri auguri**,
senza i nomi degli altri.
6. **Registro.** Nuova tabella `compleanni_notificati (giocatore_id, giorno)`: una riga per destinatario e
giorno. Il registro, e non `notifiche_utente`, impedisce il doppione anche se il giocatore elimina la
notifica (stessa logica di DD-037). Il registro non è legato a un evento: i compleanni non sono
`eventi_app`.
7. **Tipi** (`CHECK` di `notifiche_utente`): `compleanno` (l'avviso agli altri) e `compleanno_auguri` (gli
auguri). Nessun pulsante manuale: la notifica parte solo dal job. Il trigger delle push accoda
entrambi; quello delle email li lascia passare, quindi arrivano anche per email.
8. **29 febbraio: per ora non gestito.** Chi è nato il 29 febbraio viene avvisato solo negli anni bisestili,
perché il confronto è tra giorno e mese. Negli altri anni non parte nulla (né il 28 febbraio né il 1°
marzo). È un limite noto, accettato: oggi non interessa nessuno in squadra.
**Alternative scartate**
- **Card in Home, senza push né migration** → più semplice, ma non avvisa chi non apre l'app: non è ciò che
chiede la issue.
- **Un messaggio per festeggiato anche a chi lo riceve da altri** → con due compleanni lo stesso giorno
sarebbero due notifiche e due mail al giorno per lo stesso destinatario.
- **Registro per `(destinatario, festeggiato, giorno)`** → serve solo a chi vuole due avvisi separati, che
abbiamo scartato; la chiave `(destinatario, giorno)` basta.
- **Allenatori esclusi** → scartata su indicazione esplicita: «tutti».
- **Gestire il 29 febbraio il 28 febbraio** → rimandato (punto 8).
**Conseguenze**
- Ogni giorno con un compleanno ogni membro attivo riceve una notifica e, se ha account, email e le email
accese, una mail. Con molti compleanni nello stesso periodo il tetto giornaliero di invio del worker
(`mailer/`) può farsi sentire: il compleanno non ha priorità sulle altre mail.
- Chi inserisce la propria data di nascita **il giorno stesso** del compleanno, dopo le 8 e dopo che gli
altri sono già stati avvisati, non genera un secondo avviso (il registro ha già il giorno per i
destinatari). Chi la inserisce prima delle 8 viene incluso.
- Un nuovo membro che si aggiunge dopo l'avviso della giornata non riceve quello del giorno.
- Il compleanno resta anche in Calendario, invariato.
**Riesame**
Se le mail di compleanno risultano troppe, spegnerle e tenere solo push e centro notifiche (come il turno
palloni, DD-040). Se in squadra arriva qualcuno nato il 29 febbraio, gestire quel caso (punto 8).
-37
View File
@@ -1,37 +0,0 @@
# DD-048 — Via numero e data tessera dalla dashboard
**Data:** 6 ottobre 2026
**Stato:** Accettata (nessuna migration)
**Contesto**
La scheda di ogni giocatore in `/admin` aveva un blocco «Tesseramento CSI» con numero e data
tessera, scritti solo dall'admin (roadmap v1.1). Non serve più tenerli nell'app.
**Decisione**
1. Sparisce il blocco «Tesseramento CSI» dalla scheda del giocatore, con il pulsante «Salva
tesseramento» e il salvataggio (`useSalvaTesseramento()`).
2. Spariscono anche le due viste che ne dipendevano e senza il campo non avrebbero più senso:
il badge «Tesserato»/«Da tesserare» sulla scheda e il riquadro «Tesserati» della tab Squadra.
3. **Restano** l'export CSV per il tesseramento (non conteneva la tessera) e il resto del profilo
amministrativo: documento, certificato, dati personali.
4. **Dati:** le colonne `giocatori_squadra.numero_tessera` e `data_tessera` non vengono toccate
e continuano a essere lette dal codice, ma nessuna schermata le mostra né le scrive. I valori
già inseriti restano a database.
**Alternative scartate**
- Eliminare anche le colonne → scartata per ora: serve una migration e una decisione sui valori
già inseriti; si può fare dopo senza toccare l'interfaccia.
- Lasciare badge e riquadro → scartata: senza il campo resterebbero fermi su «Da tesserare» e
`0/N`, un'informazione falsa.
**Conseguenze**
- L'admin non può più registrare né correggere la tessera dall'app.
- Chi lavora sul codice trova `numeroTessera` e `dataTessera` nel tipo `GiocatoreSquadra` senza
uso in interfaccia: sono volutamente lasciati finché le colonne esistono.
**Riesame**
Se tra qualche mese nessuno ha bisogno dei valori già inseriti, eliminare le colonne con una
migration e togliere i due campi dal tipo e dalla lettura.
-53
View File
@@ -1,53 +0,0 @@
# DD-049 — Caratteri più grandi in tutta l'app
**Data:** 6 ottobre 2026
**Stato:** Accettata (solo interfaccia, nessuna migration)
**Contesto**
L'app si usa soprattutto dal telefono, anche da chi non è pratico. Molti testi erano a 12 px
(`text-xs`) e 14 px (`text-sm`): faticosi da leggere, soprattutto in Home, dove le card di
avvisi, eventi e statistiche hanno quasi solo testo piccolo.
**Decisione**
1. Tutti i testi sotto i 16 px salgono di **un passo**, in ogni pagina:
| Prima | Dopo |
| ----------------- | ----- |
| `text-xs` (12 px) | 13 px |
| 13 px | 14 px |
| 14 px | 15 px |
| `text-sm` (14 px) | 15 px |
| 15 px | 16 px |
Si usano le misure fisse `text-[13px]`, `text-[14px]`, `text-[15px]`, `text-[16px]`, già
presenti nel codice. `text-base` (16 px) e i titoli grandi non cambiano.
2. **Restano invariati** i testi delle barre a pillola dei menu, cioè la navigazione in basso
(`BottomNav`) e le sottosezioni (`BarraSottosezioni`), e i testi minuscoli da 10 e 11 px
(conteggio sulla campanella).
3. **Avvisi in Home** (certificati e turno palloni): un solo componente, `Avviso`
(`src/components/crapp/Avviso.tsx`), con titolo da 18 px, testi da 15 px, icona propria per
ogni caso (il colore non è l'unico segnale), elenchi in righe con il dettaglio a destra. Il
turno palloni ha in più la pillola «Oggi» o «In arrivo» e i messaggi in riquadri. Cambia solo
l'aspetto, non i testi né le regole su quando un avviso compare.
**Alternative scartate**
- Alzare la dimensione base del browser (`rem`) → scartata: ingrandirebbe anche icone, spazi e
barre a pillola, e ogni schermata andrebbe ricontrollata.
- Ingrandire solo la Home → scartata: le altre pagine avevano gli stessi testi piccoli.
- Salire di due passi → scartata: in contenitori stretti (badge, tessere, righe di classifica)
il testo andrebbe a capo o verrebbe troncato.
**Conseguenze**
- Un testo che stava stretto può andare a capo o troncarsi: non c'è un controllo visivo
automatico, va guardato sul telefono. Chi vede un punto rotto segnala la schermata.
- Per un testo nuovo, la scala di partenza è 13 px per le note, 15 px per il corpo, 16 px per i
titoli di card.
- Le card degli eventi sono condivise tra Home e Calendario: crescono insieme.
**Riesame**
Se dopo l'uso sul telefono compaiono testi troncati in più punti, tornare a un passo solo nei
contenitori stretti, senza toccare il resto.
-73
View File
@@ -1,73 +0,0 @@
# DD-050 — Avviso a admin e allenatori se una presenza cambia a meno di 6 ore dall'evento
**Data:** 8 ottobre 2026
**Stato:** Implementata per admin e allenatori (migration `m32`, solo admin, applicata in produzione il 9 ottobre 2026; `m33`, anche allenatori)
**Contesto**
Un giocatore può cambiare la risposta a un evento fino all'ultimo minuto. Chi organizza (admin e allenatore)
se ne accorge solo riaprendo l'evento: un «assente» detto due ore prima di una partita può far saltare la
formazione o il numero minimo per l'allenamento. Oggi nessuna notifica avvisa chi gestisce gli eventi di una
risposta (`notifiche.md`, «Cosa non genera nessuna notifica»).
**Decisione**
1. **Cosa fa scattare l'avviso.** Una riga di `risposte_presenze` che cambia — nuova, modificata o ritirata —
quando mancano **da 0 a 6 ore** all'inizio di una **partita o un allenamento** (non agli altri tipi di
evento). Scatta per **qualsiasi
cambio di stato**, da qualunque valore a qualunque altro (presente, assente, forse, in ritardo,
infortunato) e anche per la prima risposta e per il ritiro; non scatta solo se lo stato resta quello di
prima (nessun cambio reale). Vale per qualunque giocatore, anche se la
modifica la fa un admin per conto suo.
2. **Chi la riceve:** gli **admin e gli allenatori attivi** (chi ha il ruolo `admin` o `allenatore` in
`user_roles` e uno slot attivo in `giocatori_squadra`), **tranne la persona a cui appartiene la presenza**
(se un admin cambia la propria, non avvisa se stesso). Chi ha il ruolo ma nessuno slot collegato non può
ricevere notifiche, perché `notifiche_utente` è per slot.
3. **Come funziona.** Un trigger `AFTER INSERT OR UPDATE OF stato OR DELETE` su `risposte_presenze` chiama una
funzione `SECURITY DEFINER` (`avvisa_presenza_modificata()`, migration `m32`). La funzione:
1. ignora il caso in cui lo stato prima e dopo è lo stesso;
2. legge l'evento e continua solo se `tipo` è `partita` o `allenamento` e `inizio_evento(data, ora) - now()`
sta tra 0 e 6 ore (stessa `inizio_evento()` di M26: fuso `Europe/Rome`, ora illeggibile = nessun avviso);
3. inserisce una riga in `notifiche_utente` per ogni destinatario, con il tipo nuovo `presenza_modificata`.
Dal database in poi il resto è già fatto: il trigger di M22/M26 accoda l'**email**, il trigger delle push
(esteso con il tipo nuovo) accoda la **push**, la riga è il **centro notifiche in-app** (DD-038). Nessuna
route dell'app e nessun codice lato client oltre al nuovo valore di `TipoNotifica`.
4. **Testo.** Titolo `Presenza modificata: <Nome Cognome>`; corpo su righe: `Evento: <titolo>`,
`Data: <GG/MM/AAAA>, ore <ora>`, `Da: <stato prima>`, `A: <stato dopo>`. Gli stati sono «presente»,
«assente», «forse», «in ritardo», «infortunato», «nessuna risposta» (prima di una risposta nuova, o dopo
un ritiro). Non c'è un link all'evento: il pulsante «Apri CrAPP» della mail e il tocco sulla push aprono l'app.
5. **Una notifica per modifica, senza raggruppare.** `evento_id` resta NULL (come i messaggi `admin`), così la
`UNIQUE (giocatore_id, evento_id, tipo)` non si applica e due modifiche dello stesso evento non si
sovrascrivono. Contro: se la stessa persona cambia idea cinque volte, ognuno dei destinatari riceve cinque
notifiche e cinque email.
6. **Nessuna configurazione.** Né una soglia modificabile (6 ore è scritto nella funzione), né un interruttore
per ruolo: l'email e la push seguono le preferenze già esistenti di ciascuno (`preferenze_utente`, DD-036).
**Fase di prova (conclusa)**
La `m32` (9 ottobre 2026) l'ha attivata per i soli admin, per vedere in pratica quante notifiche genera. Dopo la
prova la `m33` (stesso giorno) ha aggiunto `'allenatore'` alla lista dei ruoli in `avvisa_presenza_modificata()`:
ora vale la regola del punto 2.
**Alternative scartate**
- **Una route dell'app invocata dal client dopo il salvataggio** (come `sollecita-presenze`) → il client
scrive su `risposte_presenze` direttamente con RLS: una route in più sarebbe aggirabile e dipenderebbe dal
fatto che l'app sia ancora aperta al momento del salvataggio. Il trigger copre ogni scrittura.
- **Raggruppare in una sola notifica per evento** (upsert con `evento_id`) → perde il dettaglio di chi ha
cambiato cosa, e l'upsert non rifa partire la push (il trigger delle push è solo `INSERT`).
- **Notificare anche chi ha il ruolo ma è il giocatore stesso** → inutile: sa già cosa ha fatto.
**Conseguenze**
- Nuova migration `m32` (CHECK dei tipi, tre funzioni, due trigger); `docs/DATABASE.md` e il catalogo in
`notifiche.md` si aggiornano con essa. Applicata in produzione il 9 ottobre 2026.
- L'allenatore, che prima non riceveva nulla sulle presenze, riceve questa notifica: aggiunta a
[allenatore.md](../modules/allenatore.md#notifiche).
- Rumore possibile a ridosso dell'evento (punto 5). Se in pratica dà fastidio, il primo ritocco è raggruppare
per evento, non togliere l'avviso.
**Riesame**
Dopo qualche settimana d'uso: se arrivano troppe notifiche per lo stesso giocatore, raggruppare; se 6 ore
sono troppe o poche, spostare la soglia (una costante nella funzione).
-45
View File
@@ -1,45 +0,0 @@
# DD-051 — Voto MVP definitivo e MVP a pari merito
**Data:** 26 ottobre 2026
**Stato:** Accettata (migration `m35` e `m36`)
**Contesto**
Il voto MVP si poteva cambiare senza limiti e senza storico (`mvp.md`), e una parità in testa non
assegnava l'MVP a nessuno finché non arrivavano altri voti. Con voti modificabili la classifica di una
partita poteva ribaltarsi dopo che la squadra l'aveva già vista; e una parità, che a fine serata non si
risolve più perché nessuno vota ancora, lasciava la partita senza MVP per sempre.
**Decisione**
- **Il voto è definitivo.** Il giocatore lo registra una volta sola per partita. L'interfaccia chiede una
conferma prima di inviarlo e non offre più «Cambia voto»; la mutazione è un `INSERT` e un secondo voto
viene rifiutato dal vincolo `UNIQUE (match_id, votante_id)`. A database (`m35`) il giocatore ha solo la
policy di `INSERT`: `UPDATE` e `DELETE` restano agli admin (policy di M11), che possono correggere un
voto sbagliato.
- **Una parità in testa dà più MVP.** `mvpPartita()` restituisce tutti i più votati; ognuno conta un
titolo in `mvpVintiPerGiocatore()`, e dove si mostra un solo nome per partita (Home, Classifica)
i nomi sono uniti («Anna e Bruno»). Il quorum di DD-028 (`VOTI_MINIMI_MVP = 2`) resta: sotto due voti
totali nessuno è MVP.
- **Presenza e orario a database (`m36`).** Il voto MVP richiede, oltre a convocati e non-allenatori, che
l'evento sia una partita, che votante e votato siano `presente` o `ritardo` e che siano passate 2 ore
dall'inizio. Sta in una funzione dedicata, `voto_mvp_consentito()`, perché `evento_permette_voto()` è
condivisa con pagelle e badge social, che non hanno queste regole. Gli admin restano esclusi dai
vincoli.
**Alternative scartate**
- Lasciare il voto modificabile entro una scadenza → serve una scadenza che non esiste (`mvp.md`,
«Evoluzioni possibili») e un cambio di idea tardivo resta possibile.
- Spareggio con un criterio automatico (media pagella, presenze) → introduce regole nascoste che i
giocatori non vedono; il pari merito è il risultato più semplice da spiegare.
**Conseguenze**
- Un voto dato per errore si corregge solo da un admin.
- I voti già dati restano, ma le partite con una parità in testa assegnano ora i titoli: i conteggi MVP
dei giocatori possono salire (badge «MVP» e «Uomo tie-break» compresi).
- Con tre o più pari merito (per esempio tre voti a tre persone diverse) gli MVP sono tre.
**Riesame**
Se i pari merito risultano troppo frequenti, alzare il quorum (DD-028) o introdurre uno spareggio.
-44
View File
@@ -1,44 +0,0 @@
# DD-052 — Voto MVP segreto, nome dal database e votazione che si chiude
**Data:** 26 ottobre 2026
**Stato:** Accettata (migration `m37` e `m38`)
**Contesto**
Dopo DD-051 il voto è definitivo e controllato a database, ma restavano tre difetti: chiunque poteva
leggere da `mvp_voti` chi aveva votato chi; `votato_nome` lo scriveva il client e poteva non
corrispondere a `votato_id` (il titolo andava all'id, il nome mostrato era un altro); e la votazione,
una volta aperta, non si chiudeva mai.
**Decisione**
- **Voto segreto.** La lettura diretta di `mvp_voti` è del solo votante (il proprio voto) e degli admin.
La squadra legge i conteggi dalla vista `mvp_voti_anonimi`, una riga per voto senza `votante_id` e
ordinata per candidato (la sequenza di arrivo non rivela nulla). La vista gira con i permessi del
proprietario: è ciò che le permette di leggere tutte le righe senza esporre chi ha votato. Non è
leggibile senza account.
- **Nome dal database.** Un trigger riempie `votato_nome` da `giocatori_squadra` a partire da
`votato_id`, ignorando quello inviato (anche se omesso, vuoto o diverso; e anche quando un admin corregge
un voto). Se l'id non è in rosa resta il nome ricevuto. Lo stesso trigger vale per i badge social
(`m38`), che avevano lo stesso difetto; le pagelle non hanno un nome del votato.
- **Chiusura.** Il voto si apre 2 ore dopo l'inizio della partita e si chiude 24 ore dopo l'apertura
(cioè 26 ore dopo l'inizio). A database lo impone `voto_mvp_consentito()`; nell'app `votoMvpChiuso()`.
Gli admin restano fuori dal vincolo.
- **Tutte le partite.** Il voto vale per ogni evento di tipo partita, amichevoli comprese (invariato).
**Alternative scartate**
- Filtrare i voti solo nell'app → chi scrive su PostgREST li leggerebbe comunque.
- Togliere la lettura anche agli admin → non potrebbero più correggere un voto sbagliato; restano gli
unici a poter leggere la tabella, e l'app non mostra a nessuno chi ha votato chi.
- Chiudere 24 ore dopo l'inizio della partita (22 ore di voto) → si è scelto 24 ore piene dall'apertura.
**Conseguenze**
- Le partite già chiuse da più di 26 ore non si votano più: chi non aveva votato perde la possibilità.
I voti già dati restano e contano.
- I conteggi restano visibili durante la votazione: chi vota può vedere chi è in testa.
- Un admin che legge la tabella dal database vede ancora chi ha votato chi.
**Riesame**
Se si vuole un voto davvero cieco, nascondere i risultati fino alla chiusura (la chiusura a 24 ore lo
rende possibile).
-40
View File
@@ -1,40 +0,0 @@
# DD-053 — Valutazioni − / + dei fondamentali nello Scout Live
**Data:** 2 novembre 2026
**Stato:** Accettata (migration `m39`)
**Contesto**
Lo scout registrava solo ciò che fa punto o lo toglie (attacco, ace, muro, errore). Mancava un
giudizio sul resto del gioco — battuta, ricezione, attacco, muro — che non cambia il punteggio.
**Decisione**
- Per ogni convocato, un **−** o un **+** su battuta, ricezione, attacco e muro, **per set**.
- Sono valutazioni, non azioni: lista `valutazioni` separata da `azioni`, così non entrano nel
punteggio, nei parziali né nei totali P/A/M/E (`totaliPerGiocatore`, badge, classifiche: DD-008).
- Ogni tocco è una riga (giocatore, fondamentale, segno, set, istante): permette l'annulla e il
conteggio per set senza strutture da tenere allineate.
- Stato in corso dentro `scout_live.stato` (jsonb, nessuna modifica); partita archiviata nella
nuova colonna `scout_partite.valutazioni jsonb NOT NULL DEFAULT '[]'` (`m39`, solo additiva).
- Nella schermata dello scout, su richiesta, sono stati tolti i tasti delle azioni, il tabellone del
punteggio e il blocco avversario/campo: lo scout è fatto solo di valutazioni. Non c'è più una
«Chiudi set» e «Fine partita» funzionano anche senza punti: il set chiuso vale 0-0 e la partita
archiviata di sole valutazioni **non è un risultato** (`haPunteggio()`), quindi non compare in
Home, Classifica né al posto del referto CSI.
- Il salvataggio dello stato in corso è immediato a ogni tocco, con avviso se fallisce.
**Alternative scartate**
- Nuovi `AzioneTipo` dentro `azioni` → ogni consumatore (punteggio, totali, CSV) andrebbe filtrato
per non contarli come punti.
- Solo contatori aggregati per giocatore/set → niente annulla preciso, né ordine cronologico.
- Tabella dedicata → per un dato che nasce e muore con la partita archiviata è più schema del
necessario.
**Conseguenze**
Le partite già archiviate hanno `valutazioni = []`. Le valutazioni sono visibili solo nel CSV
admin; sono dato di squadra a uso tecnico, non entrano in classifiche individuali.
**Riesame**
Se servirà confrontare le valutazioni tra partite o mostrarle ai giocatori.
-113
View File
@@ -1,113 +0,0 @@
# Registro delle decisioni di progetto
Ogni file di questa cartella è **una decisione** (`DD-XXX.md`): una scelta che ha influito sulla
direzione del prodotto, sull'organizzazione del lavoro o su come l'app si evolve. Non descrive
_come_ è fatto il codice (per quello esistono [ARCHITECTURE.md](../ARCHITECTURE.md) e
[DATABASE.md](../DATABASE.md)): risponde a _perché abbiamo scelto così_, _cosa avevamo escluso_
e _quando conviene riaprire la scelta_.
**Leggi solo le decisioni che ti servono**: cerca il tema nell'indice qui sotto e apri il file
corrispondente. Non serve leggerle tutte.
## Come usare questo registro
Ogni decisione segue lo schema di [../_template-dd.md](../_template-dd.md):
| Campo | Significato |
| ------------------------ | ------------------------------------------------------ |
| **Data** | Quando la decisione è stata presa o confermata |
| **Stato** | Accettata · In valutazione · Sostituita · Obsoleta |
| **Contesto** | Quale problema o opportunità avevamo di fronte |
| **Decisione** | Cosa abbiamo scelto di fare |
| **Alternative scartate** | Cosa non abbiamo fatto e perché |
| **Conseguenze** | Cosa comporta nel quotidiano (utenti, admin, sviluppo) |
| **Riesame** | Quando ha senso riconsiderarla |
**Quando aggiungere una voce**
- una scelta influisce su più moduli o su più release;
- escludiamo un'alternativa non ovvia;
- accettiamo un compromesso consapevole (debito, limitazione, ritardo);
- cambiamo una decisione precedente.
**Quando non serve**
- dettagli implementativi locali;
- scelte estetiche minori;
- bugfix o correzioni puntuali.
**Come registrare una nuova decisione**
Copiare [../_template-dd.md](../_template-dd.md) in `docs/decisions/DD-XXX.md` con il primo ID
libero (gli ID non si riusano e non si rinumerano), poi aggiungere la riga all'indice qui sotto.
Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimanda a quella nuova.
## Indice
**Accettate**
| ID | Titolo |
| ------------------- | --------------------------------------------------------- |
| [DD-001](DD-001.md) | Indipendenza da Lovable |
| [DD-002](DD-002.md) | Sviluppo document-first |
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
| [DD-005](DD-005.md) | Mobile-first |
| [DD-006](DD-006.md) | AI solo se utile |
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
| [DD-010](DD-010.md) | Niente storico certificati |
| [DD-011](DD-011.md) | Auth reale prima del profilo |
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
| [DD-013](DD-013.md) | Portabilità dello stack |
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
| [DD-018](DD-018.md) | Collegamento automatico per email |
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
| [DD-020](DD-020.md) | Test obbligatori e verdi |
| [DD-021](DD-021.md) | Molle interrompibili con motion |
| [DD-022](DD-022.md) | App solo chiara |
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
| [DD-024](DD-024.md) | Route di notifica autenticate |
| [DD-025](DD-025.md) | Promemoria palloni manuale |
| [DD-026](DD-026.md) | Payload push cifrato |
| [DD-027](DD-027.md) | Voto limitato ai convocati |
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
| [DD-030](DD-030.md) | Centro notifiche in-app |
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
| [DD-034](DD-034.md) | Ruolo allenatore |
| [DD-035](DD-035.md) | Avviso certificati in Home |
| [DD-036](DD-036.md) | Notifiche email via Gmail |
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
| [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
| [DD-051](DD-051.md) | Voto MVP definitivo e MVP a pari merito |
| [DD-052](DD-052.md) | Voto MVP segreto, nome dal database e chiusura |
| [DD-053](DD-053.md) | Valutazioni − / + dei fondamentali nello Scout Live |
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno |
| [DD-046](DD-046.md) | Avviso certificati mancanti |
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
**In valutazione**
| ID | Titolo |
| ------------------- | --------------------- |
| [DD-014](DD-014.md) | Convergenza schema DB |
**Sostituite**
| ID | Titolo |
| ------------------- | --------------------- |
| [DD-003](DD-003.md) | Branch main / develop |
-213
View File
@@ -1,213 +0,0 @@
# Modulo — Ruolo Allenatore
**Stato:** implementato
**Decisione:** [DD-034](../decisions/DD-034.md)
**Migration:** `m20_ruolo_allenatore_enum`, `m21_ruolo_allenatore`
**File principali:** `src/lib/giocatori-squadra.ts` (`tipo`, `inRosa`, `isAllenatore`,
`ruoloVisibile`), `src/lib/ruoli.ts`, `src/lib/user-store.ts` (`useSonoAllenatore`,
`useGiocatoreInCampo`), `src/lib/auth-route.server.ts`, `src/routes/profilo.tsx`,
`src/routes/admin.tsx`, `src/routes/squadra.tsx`, `src/routes/index.tsx`
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/profili-core.test.ts`,
`test/unit/presenze.test.ts`, `test/integration/permessi-allenatore.test.ts`,
`test/integration/permessi-route.test.ts`
## Obiettivo
Dare accesso all'app all'allenatore, con permessi diversi da quelli di un giocatore: gestisce
gli eventi (allenamenti, partite, amichevoli) e sollecita le presenze, ma non gioca: compare
in Squadra come membro con la dicitura «Allenatore», però non è convocabile, non vota, non
riceve voti e non ha statistiche, badge o stagione.
## Registrazione
Stesso flusso dei giocatori (DD-018), senza passaggi in più:
1. L'admin, da `/admin` → **Aggiungi**, sceglie il tipo **Allenatore** invece di Giocatore e
inserisce nome, cognome ed email Gmail. Numero di maglia e ruolo in campo non si chiedono.
2. L'allenatore accede con Google; l'email corrisponde allo slot e il collegamento è
automatico. Se l'email non corrisponde, stesso messaggio di errore dei giocatori.
3. Entra in Home.
Il permesso nasce dalla registrazione fatta dall'admin: l'allenatore non può
auto-promuoversi, perché il tipo dello slot lo scrive solo un admin (come oggi numero e ruolo).
## Profilo
Il profilo dell'allenatore ha solo due tab (quelle del giocatore sono quattro):
| Tab | Giocatore | Allenatore |
| ------------ | --------- | ---------------------------------------------- |
| Stagione | sì | **no** |
| Badge | sì | **no** |
| Documenti | sì | sì, ridotta (vedi sotto); tab iniziale |
| Impostazioni | sì | sì, identica (logout, notifiche, segnalazioni) |
Un link diretto a `/profilo?tab=stagione` o `?tab=badge` apre Documenti.
### Dati modificabili dall'allenatore
| Campo | Dove sta oggi | Nota |
| ---------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
| Nome, cognome | `giocatori_squadra` | per un giocatore li scrive solo l'admin; per l'allenatore anche lui sul proprio slot |
| Email (contatto) | `profili_giocatore.email` | non è l'email di login, che resta quella registrata dall'admin |
| Data di nascita | `profili_giocatore.data_nascita` | sincronizzata in `giocatori_squadra.nascita` come per tutti (DD-031): compleanno in Calendario |
| Luogo di nascita | `profili_giocatore.luogo_nascita` | |
| Telefono | `profili_giocatore.telefono` | |
| Foto profilo | bucket `avatar-giocatori` | stesso cerchio avatar e stesso upload dei giocatori |
**Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico,
tesseramento CSI.
### Completamento profilo
Per l'allenatore il completamento conta solo data e luogo di nascita, telefono ed email, 25%
ciascuno (`completamentoAllenatore()`). Il widget in Home segue la stessa regola: sparisce
quando quei campi sono compilati.
## Home
La Home dell'allenatore tiene ciò che riguarda la squadra e toglie ciò che riguarda il
giocatore: restano classifica e bilancio, prossimo impegno e ultima partita; spariscono lo
streak personale, il promemoria palloni, «Da confermare», l'obiettivo di squadra e il
«Colpo d'occhio» con le statistiche personali. Non compare nemmeno l'avviso certificati: lo
vedono solo gli admin e il giocatore interessato (DD-035).
## Squadra
L'allenatore compare nella tab **Rosa** insieme ai giocatori, con foto, nome e cognome. Dove
per un giocatore c'è il ruolo in campo (palleggiatore, schiacciatore…) per lui c'è scritto
**Allenatore**, ricavato da `tipo` e non dal campo `ruolo`. Non ha numero di maglia né
statistiche, e non compare nella tab Stats né nelle classifiche.
Da allenatore, in `/squadra` vede solo **Rosa** e **Stats**: le tab **Obiettivi** e **Badge**
non compaiono.
| Tab | Giocatore | Allenatore |
| --------- | --------- | ---------- |
| Rosa | sì | sì |
| Stats | sì | sì |
| Obiettivi | sì | **no** |
| Badge | sì | **no** |
## Badge e cacche nascosti
L'allenatore non vede **nessun badge** e **nessuna classifica o dato sulle cacche**, in
nessuna schermata:
| Dove | Cosa si nasconde all'allenatore |
| ----------------------------------- | -------------------------------------------------------------------------------- |
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche 💩» |
| `/partita/$id` | la sezione «Badge votati dai compagni» e il sondaggio cacche (`SondaggioCacche`) |
| push «Sondaggio pre-partita aperto» | non parte verso i dispositivi degli allenatori (`apri-sondaggio.ts`) |
| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) |
Presenze, media voto, MVP e palloni restano visibili.
## Gestione eventi
L'allenatore ha su `/eventi` gli stessi poteri dell'admin: crea, modifica, sposta ed elimina
eventi e sceglie i convocati (l'elenco dei convocati contiene solo giocatori). Nel database
la policy di scrittura su `eventi_app` è "admin o allenatore" (M21); la cancellazione a
cascata (DD-029) resta invariata. Il bottone «Gestisci eventi» del Calendario compare anche
a lui (`usePuoGestireEventi()`).
Può anche **sollecitare le presenze** di un evento, come l'admin: la route
`sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso. Con DD-040 si aggiungono un sollecito automatico 24 ore prima dell'evento, ma il pulsante resta.
**Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI,
notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle
risposte presenze altrui, gestione dei ruoli.
## Notifiche
Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i canali di tutti, sta in
[notifiche.md](notifiche.md#catalogo-delle-notifiche)):
| Notifica | L'allenatore la riceve? | Note |
| ------------------------------------------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Promemoria a 24 e 3 ore prima di un evento (rimosso, DD-043) | **no** | Dal 29/09/2026 (m25, DD-039) `giocatori_destinatari_evento()` include solo i giocatori attivi: l'allenatore non può rispondere alle presenze né essere convocato, quindi il promemoria non gli chiederebbe nulla che possa fare (per esempio una cena di squadra). Prima gli arrivava sempre, convocato o no (m21). |
| Messaggio dello staff | **sì** | In-app ed email vanno a tutta la rosa attiva, allenatori compresi; la push ai suoi dispositivi iscritti, o solo a lui se il messaggio è per lui. |
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . |
| Turno palloni | no | I turni riguardano solo i giocatori. |
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. |
| Presenza modificata (DD-050, `m33`) | **sì** | Riceve l'avviso quando un giocatore cambia, mette o ritira la risposta a meno di 6 ore da una partita o un allenamento, come gli admin. In-app, email e push. |
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
spento l'interruttore «Email»; la push solo sui dispositivi su cui ha attivato «Notifiche» dal
proprio Profilo (l'interruttore è identico a quello dei giocatori). Il centro notifiche in-app
funziona come per i giocatori, con i soli messaggi dello staff.
## Esclusione dalla rosa
L'allenatore ha uno slot in `giocatori_squadra` (DD-034), quindi ogni lettura della "rosa di
gioco" lo esclude con `inRosa()` (`attivo` e `tipo = giocatore`) al posto del vecchio filtro
su `attivo`. Fanno eccezione la tab Rosa di Squadra, i compleanni del Calendario
(`useAnagraficaRosa({ conAllenatori: true })`) e i destinatari dei messaggi admin, che lo
includono.
| Livello | Dove si esclude l'allenatore |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial`, `useIo()` e `useGiocatoreCorrente()` (quindi le notifiche smart) |
| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni |
| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` |
| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore |
| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) |
Il compleanno dell'allenatore resta visibile in Calendario (è un membro della squadra, non un
giocatore).
## Permessi (riepilogo)
| Azione | Giocatore | Allenatore | Admin |
| ------------------------------------------------------------------- | ---------- | ------------------ | ----------------------------------- |
| Modificare nome e cognome | no | sì, i propri | tutti |
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
| Documento, certificato | i propri | — | lettura/download |
| Avviso certificati in Home | il proprio | no | tutta la rosa |
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
| Sollecitare le presenze | no | sì | sì |
| Promemoria palloni, notifica personalizzata | no | no | sì |
| Ricevere i promemoria automatici degli eventi (in-app, email, push) | sì | no (DD-039) | secondo il proprio slot |
| Ricevere i messaggi dello staff | sì | sì | secondo il proprio slot |
| Ricevere sollecito presenze, turno palloni, sondaggio cacche | sì | no | solo con uno slot giocatore in rosa |
| Ricevere le notifiche smart (badge, serie, obiettivi) | sì | no | solo con uno slot giocatore in rosa |
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
| Tab Stagione e Badge del profilo | sì | no | sì |
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
| Badge e classifiche/dati sulle cacche | sì | no | sì |
| Dashboard `/admin` | no | no | sì |
Le notifiche sono legate allo **slot** (`giocatori_squadra`), non al ruolo di admin: chi è admin le riceve
secondo il proprio slot, come giocatore o come allenatore, e non ne riceve se non ha uno slot.
## Implementazione
- **Migration M20** aggiunge `allenatore` all'enum `app_role`: sta da sola perché Postgres non
lascia usare un valore di enum nella transazione che lo crea.
- **Migration M21**: colonna `giocatori_squadra.tipo`; `numero` nullabile solo per gli
allenatori (vincolo `giocatori_squadra_numero_giocatori`); trigger
`enforce_giocatori_squadra_update` con `tipo` bloccato e nome/cognome liberi solo sullo slot
del proprio allenatore; policy «L'allenatore aggiorna il proprio slot»; trigger
`sincronizza_ruolo_allenatore` che aggiunge o toglie la riga `allenatore` in `user_roles` al
collegamento, scollegamento, disattivazione o cambio di tipo; policy `eventi_app` admin o
allenatore; presenze, cacche e voti chiusi all'allenatore.
- **Client**: nell'app `numero` resta un `number` (0 per l'allenatore, NULL a database). I
permessi (eventi, sollecito) leggono `user_roles` con `usePuoGestireEventi()`; cosa si vede
dipende dal tipo del proprio slot (`useSonoAllenatore()`), che è immediato.
- **Admin**: «Aggiungi giocatore o allenatore» con il campo Tipo; per l'allenatore il form non
chiede numero e ruolo. Il tipo si sceglie solo alla creazione.
## Scelte confermate
- Non riceve i promemoria degli eventi (DD-039; fino alla m24 li riceveva sempre, DD-034). Riceve i
messaggi dello staff.
- Sollecita le presenze: sì. Promemoria palloni: no.
- Badge e cacche: mai visibili all'allenatore, né classifiche né sondaggio.
- In Squadra: nessuna sezione Staff; compare in Rosa come gli altri, con «Allenatore» al
posto del ruolo.
- Vede le risposte presenze e le pagelle dei giocatori: sì, la lettura è già aperta a tutti
gli autenticati.
-240
View File
@@ -1,240 +0,0 @@
# Modulo — Badge
**Stato:** implementato, coerente con DD-007 e DD-008
**File principali:** `src/lib/badges.ts`, `src/lib/badge-social.ts`,
`src/components/crapp/CollezioneBadge.tsx`, `src/components/crapp/BadgeDrawer.tsx`,
`src/components/crapp/CelebrazioneBadge.tsx`, `src/components/crapp/VotoSocial.tsx`
---
## Obiettivo
Gamification: sbloccare badge (gradi bronzo/argento/oro, più badge "segreti") in base a
statistiche personali reali del giocatore, per motivare la partecipazione senza penalizzare i
ruoli con meno statistiche "spettacolari" (DD-008).
---
## Dati
Nessuna tabella dedicata ai badge sbloccati: **calcolati interamente a runtime**
dall'oggetto `Giocatore` (DD-007). L'unica tabella coinvolta è `badge_social_voti`, per i
badge assegnati per voto dai compagni.
---
## Implementazione
- `badgeDefs`/`badgeSegreti` (`badges.ts`) definiscono ogni badge con una funzione
`valore(g)` e tre soglie bronzo/argento/oro (elenco completo con fonte e soglie di ognuno in
"Elenco badge" sotto). Il grado è calcolato da `gradoRaggiunto()`/`statoBadge()`: soglie
inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è
già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni,
ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a
confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il
Pagellone, lo Sherpa dei palloni, l'MVP e i badge social.
- **Badge Sherpa dei palloni** (`palloni`, in `badgeDefs`): `g.palloni` non è un contatore
incrementato a ogni evento, ma ricalcolato da `conteggioTurni()` (`palloni-core.ts`) su
`Giocatore.palloni` (`rosa.ts`) — meccanismo di turni/rotazione descritto per intero in
[palloni.md](palloni.md), non ripetuto qui. `rosa.ts` passa a `conteggioTurni()` **solo i
turni confermati** (`turniSalvati` da `useTurniPalloni()`), non l'output di
`completaTurni()`: le proposte automatiche di rotazione (usate altrove, per la UI di
`TurnoPalloni.tsx`) non contano per il badge, che premia solo chi ha davvero confermato di
aver portato i palloni. Conta solo per eventi già trascorsi (`e.data < oggi`, stesso
criterio delle presenze).
- **Badge Pagellone** (`pagella`, in `badgeDefs`): a differenza degli altri badge da
contatore, richiede un numero minimo di voti (`VOTI_MINIMI_PAGELLA = 5`, `badges.ts`) prima
che `g.mediaVoto` conti — sotto soglia `valore(g)` è forzato a `0` (badge bloccato), anche
con una media altissima. Aggiunto perché senza minimo un singolo voto poteva
sbloccare/far sparire il badge senza nessuna significatività statistica (vedi
[pagelle.md](pagelle.md) per la pipeline voto → media, qui non ripetuta). Il numero di voti
ricevuti arriva in `Giocatore.votiPagella` (`rosa.ts`), popolato insieme a `mediaVoto` dalla
stessa `mediePagelle()`.
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per
partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita
(modificabile), con **auto-voto escluso in interfaccia** (`VotoSocial.tsx`) e rifiutato dal
database (vincolo `badge_social_no_autovoto`, migration `m12_niente_autovoto`). A
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
`votante_id`/`votato_id` sono entrambi visibili.
- **Badge MVP** (`mvp`, in `badgeDefs`): l'unico badge normale la cui fonte non è un contatore
già pronto ma il risultato della votazione MVP tra compagni — meccanismo di voto (chi vota
chi, apertura, autovoto, RLS) descritto per intero in [mvp.md](mvp.md), non ripetuto qui.
Quello che serve per capire il badge: `mvpVintiPerGiocatore()` (`mvp-voti.ts`) conta, per
ogni giocatore, quante partite ha vinto con un **vantaggio netto** sul secondo (non il
totale dei voti ricevuti; in caso di parità la partita non conta per nessuno). `rosa.ts`
(`useRosa()`) scrive quel numero in `Giocatore.mvp`, che `badgeDefs` legge con
`valore: (g) => g.mvp` e confronta con le soglie 1/3/5 (bronzo/argento/oro).
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
badge segreti ancora da scoprire; `BadgeDrawer.tsx` il dettaglio di un singolo badge;
`CelebrazioneBadge.tsx` l'overlay celebrativo alla prima visualizzazione di un badge nuovo.
- Il rilevamento "nuovo" (`notifiche-smart.ts`) confronta id deterministici con quelli già
visti, salvati in `localStorage` — quindi **locale al dispositivo**, non sincronizzato tra
dispositivi dello stesso giocatore.
---
## Elenco badge
Riferimento completo per chi lavora sul codice. **In app i 4 badge segreti restano nascosti
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
informazioni verso l'interfaccia.
### Badge normali (gradi bronzo/argento/oro)
Tutti calcolati come `valore(g)` confrontato con tre soglie crescenti; il grado è l'ultima
soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
| id | nome | come si guadagna | soglie B/A/O |
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 |
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 |
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
### Badge segreti (booleani, nascosti finché non sbloccati)
Stesso motore dei normali ma con soglie `{bronzo:1, argento:1, oro:1}`: `valore(g)` è 0 o 1,
quindi il badge è "trovato o no", mai graduato. In UI compaiono con icona lucchetto finché non
sbloccati. Attenzione se si tocca `gradoRaggiunto()`: con le tre soglie tutte uguali a 1, il
grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo che il ciclo
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
| id | nome | condizione esatta |
| -------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
### Badge social (votati dai compagni, 5 categorie per partita)
Non hanno gradi: si "vince" o non si vince una categoria in una partita. `vincitoreCategoria()`
richiede un vantaggio netto sul secondo classificato, in parità nessun vincitore.
`badgeSocialVinti()` conta quante partite ha vinto ciascun giocatore in ogni categoria (non i
voti ricevuti).
| id | nome | cosa premia |
| ------------ | -------------------------- | ------------------------------------------------ |
| `affidabile` | Compagno affidabile | sempre presente, sempre sul pezzo |
| `spirito` | Miglior spirito di squadra | carica il gruppo dal primo all'ultimo punto |
| `fairplay` | Fair play | rispetto per compagni, avversari e arbitro |
| `meme` | Meme della partita | la scena più memorabile della partita |
| `cuore` | Cuore del gruppo | chi tiene unita la squadra anche fuori dal campo |
---
## Regole rispettate
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
esistenti.
- **DD-008**: nessun `BadgeDef` usa dati di reparto (punti/ace/muri); solo statistiche
raggiungibili da qualunque ruolo.
---
## Copertura test
Tutti e 16 i badge hanno test unit (`test/unit/badges.test.ts`, `badge-social.test.ts`) e di
integration end-to-end sul database locale (`test/integration/*-badge.test.ts`,
`badge-social.test.ts`, più `scritture` e `permessi`). La logica pura sta in `badges.ts`; i test
verificano soglie inclusive (1→bronzo, soglia decimale 6.49 ≠ 6.5), confini dei badge segreti e
la soglia minima di voti della Pagella (`VOTI_MINIMI_PAGELLA`). Il dettaglio test per test lo
dicono i file, non questa pagina: leggili prima di cambiare una soglia.
### Riepilogo per badge
| # | id | tipo | test unit | test integration |
| --- | ------------------- | ------------ | ----------------------------- | --------------------------------------------- |
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
| 6 | 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
| 6 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
| 7 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
| 8 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
| 9 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
| 10 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 11 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 12 | `meme` | social | ✅ | ✅ (`badge-social`) |
| 13 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
---
## Problemi noti da sistemare
- **`badgeSbloccati()` morta** (`badges.ts:283-285`): duplica esattamente
`collezioneBadge(g).sbloccati`. Zero riferimenti fuori dalla propria definizione, né in
`src/` né nei test. Da rimuovere o documentare perché esiste (es. uso futuro/esterno).
- **`categoria` senza vincolo DB** in `badge_social_voti`: la colonna è `text NOT NULL` senza
CHECK o FK verso i 5 id di `categorieSocial`
(`supabase/migrations/20260803140647_affa1c11-fa92-450f-9f00-02d87195a6d9.sql:4`). I test
stessi lo dimostrano scrivendo categorie inesistenti (`"sorriso"`/`"urlo"`,
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
`eventiPalloni()` che include solo partite e allenamenti. Un turno registrato per errore su
un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
palloni. Rischio teorico basso — l'UI non offre questa combinazione, dato che
`TurnoPalloni`/`completaTurni()`/i promemoria ora si fermano a `eventiPalloni()` — comportamento
pinnato da un test dedicato in `palloni-core.test.ts` così che un domani, se serve stringere,
non lo si scopra rompendo un test esistente ma leggendo perché quel test lo dimostrava apposta.
---
## Limiti noti
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: il badge «Sempre in palestra» si muove solo se cambia la serie
Allenamenti. Il badge «Risposta lampo» e il segreto «Mai un forfait», basati sulla serie «Conferme 24h», sono stati
rimossi il 2026-10-06.
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
"ottenuto" può sparire o apparire retroattivamente.
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
browser o dispositivo.
**Risolto (analisi del badge Sherpa dei palloni)**: prima `g.palloni` (`rosa.ts`) includeva
anche i turni che `completaTurni()` propone in automatico per un evento passato senza
assegnazione esplicita, non solo quelli confermati in `turni_palloni` — un giocatore poteva
vedere avanzare il badge senza aver mai confermato nulla, semplicemente perché l'algoritmo di
rotazione l'aveva proposto. Ora `rosa.ts` passa a `conteggioTurni()` solo `turniSalvati` (i
turni confermati), non l'output di `completaTurni()`: quest'ultimo resta in uso solo per la
UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteggio del badge.
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
conseguenza.
**Risolto (audit completo dei badge (all'epoca 16))**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
`g.votiPagella >= VOTI_MINIMI_PAGELLA`, dimostrato con dati reali in `s-tiebreak-badge.test.ts`.
Il badge `s-cacche` prometteva invece "partite di **campionato**" nella descrizione senza che
nessuna funzione della pipeline lo verificasse mai (`statisticheCacche()` conta qualunque
partita) — qui si è scelto di correggere la descrizione, non il codice: il comportamento
"qualunque partita conta" resta quello voluto, pinnato in `s-cacche-badge.test.ts`.
**Risolto (M13, `20260908120000_m13_convocati_e_pagelle_chiuse.sql`)**: prima la policy di M11
garantiva solo che il voto fosse firmato con il proprio `votante_id`, non che il votato (né il
votante) fossero convocati per quella partita — filtro solo applicativo, aggirabile scrivendo
direttamente su PostgREST. Ora `evento_permette_voto()` lo verifica anche a database per
`pagelle_voti`, `mvp_voti` e `badge_social_voti` (convocati vuoto = tutta la rosa, stessa
convenzione di `convocatiEvento()`), e per le sole pagelle verifica anche che
`eventi_app.pagelle_chiuse` sia falso — prima un voto "fuori tempo" restava tecnicamente
possibile bypassando l'interfaccia. Le policy admin restano permissive: un amministratore può
ancora correggere un voto anche fuori convocazione o dopo la chiusura.
---
## Evoluzioni possibili
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
- Verificare sui dati di stagione che il badge legato alla serie si sblocchino davvero,
ora che le serie sono calcolate.
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
- Se un domani serve restringere `conteggioTurni()` per tipo evento (vedi "Problemi noti"),
aggiornare anche il test che oggi ne pinna il comportamento permissivo.
-91
View File
@@ -1,91 +0,0 @@
# Modulo — Calendario ed Eventi
**Stato:** implementato
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx`
(card condivisa), `src/routes/allenamento.$id.tsx`, `src/routes/partita.$id.tsx`,
`src/routes/evento.$id.tsx` (schede di dettaglio, una per tipo)
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
---
## Obiettivo
Un unico calendario condiviso per allenamenti, partite, amichevoli ed eventi extra
(riunioni, cene di squadra...), al posto di messaggi sparsi in chat. Ogni evento in
`eventi_app` diventa il punto a cui si agganciano presenze, convocazioni, MVP, pagelle,
scout e turno palloni — la maggior parte degli altri moduli dipende da un `evento.id`.
## Due schermate, due pubblici
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
true })` — ci sono anche gli allenatori, DD-034 — non righe
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
di evento, i giorni con più eventi si dividono lo spazio. In fondo alla pagina la sezione
«Prossimi compleanni» elenca solo quelli del **mese in corso** non ancora passati (oggi
compreso), a prescindere dal mese mostrato nella griglia: il 6 ottobre un compleanno del 3
non compare più. Le celle della griglia continuano a mostrare tutti i compleanni del mese
selezionato.
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
(`usePuoGestireEventi()`, [DD-034](../decisions/DD-034.md)):
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
(`convocatiEvento()`, vuoto = tutta la rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). La pagina mostra
solo una griglia mensile (stessa logica di `/calendario`, tramite le funzioni condivise di
`src/lib/calendario.ts`), che si scorre di mese in mese con le frecce o con lo swipe, con
la stessa fisica a molla di `/calendario`. Ogni giorno è cliccabile, anche senza eventi, e
apre un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento
in questo giorno" che apre il form con la data già precompilata: creare, modificare ed
eliminare passano solo da lì. Nel form il giorno si legge come testo fisso e resta da
scegliere solo l'ora; il campo data compare solo toccando «Cambia», per spostare l'evento. Fino a 0.9.2 sotto la griglia c'era anche la lista
cronologica di tutti gli eventi, tolta perché ripeteva il calendario
([DD-033](../decisions/DD-033.md)).
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, `/evento/$id` per un evento
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
`eventi_app`.
**Link Google Maps.** `eventi_app.link_maps` (M34, facoltativo) si imposta dal form di `/eventi`
sotto «Luogo»: si incolla il link di «Condividi» di Google Maps. `linkMapsValido()` in `eventi.ts`
accetta solo URL https di Google Maps (`maps.app.goo.gl`, `goo.gl/maps`, `google.*/maps`,
`maps.google.*`) e lo stesso criterio è un CHECK sul database. Le tre schede di dettaglio mostrano
«Apri su Maps» (nuova scheda) solo se il link c'è; non c'è un link generato dal testo del
luogo, e né la card né le notifiche lo mostrano.
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
`palloni.md`, non pertinente per gli eventi extra-campo) e lo stato `infortunato` escluso
dalle risposte solo sulla scheda evento (vedi `presenze.md`).
## Lettura lato server
`src/lib/eventi.server.ts` (`leggiEventi()`) è la stessa conversione riga→modello di
`eventi.ts`, ma con `supabaseAdmin` per le route API che girano senza sessione utente (es.
`sollecita-presenze.ts`, `promemoria-palloni.ts` — vedi `presenze.md` e `palloni.md`) e per
`notifiche-smart.ts`, che decide i promemoria da mandare in base agli eventi del giorno.
---
## Limiti noti
1. **Cancellare un evento è distruttivo per tutto ciò che vi era agganciato.** Un trigger
(`m14_pulizia_dati_evento_cancellato`,
[DD-029](../decisions/DD-029.md))
pulisce a cascata presenze, cacche, voti MVP/pagelle/badge social, turni palloni e scout
di quell'evento: non è recuperabile con un annulla, e prima di M14 quelle righe restavano
orfane nel database (bonificate una tantum da M15/M16, vedi [DATABASE.md](../DATABASE.md)).
2. **Nessuna creazione automatica degli eventi partita dal calendario CSI.** Le gare
ufficiali arrivano già come dati (`getEventsByTeamId.php`, vedi `collegamento-csi.md`),
ma un amministratore deve comunque creare a mano l'evento corrispondente in `/eventi`
perché esistano convocazioni, presenze, MVP e pagelle per quella partita — altrimenti la
gara resta visibile solo nello storico CSI, con un dettaglio "solo CSI" più povero
(`/partita-csi/$id` invece di `/partita/$id`). In `docs/ROADMAP.md` sotto "Prossimo".
-350
View File
@@ -1,350 +0,0 @@
# Modulo — Collegamento CSI
**Stato:** implementato (stagione 2026/27)
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
(dettaglio di una gara: formazioni e scontri diretti)
---
## Obiettivo
Mostrare nell'app la classifica e i risultati **ufficiali** del campionato CSI, al posto
dei dati dimostrativi hardcoded in `crapp-data.ts`. Nessun inserimento manuale da parte
degli amministratori: è esattamente il tipo di lavoro amministrativo che CrAPP deve togliere.
---
## Sorgente dati
Portale **Livescore CSI Bologna** (`https://livescore.csibologna.it`).
Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi endpoint
che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza
API key, ma **non offrono alcuna garanzia di stabilità**.
Le pagine "umane" (`league_details.php`, `team_details.php`) sono gusci lato server: non
contengono dati, li caricano dopo via JS dagli stessi endpoint `components/*.php` —
verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati in fondo
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
un `project_id`), nessun codice le chiama direttamente.
### Identificativi (stagione 2026/27)
| Cosa | Valore |
| --------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` (girone) | `933` |
| Coppa | nessuna: la squadra non è iscritta |
| `project_id` (coppa) | `null` |
| Squadra sul portale | `C.R.A.P. Volley` |
| `team_id` | `3878` |
| Girone | B (7 squadre) |
`project_id` (933), `CSI_COPPA_PROJECT_ID` (`null`) e `team_id` (3878) sono costanti in
`src/lib/csi-core.ts`.
**A ogni stagione il CSI crea una nuova squadra, con un nuovo `team_id`.** Il 2025/26 era
`3359` (campionato `767`, Coppa `848`); verificato il 29/10/2026 che `getEventsByTeamId.php`
per `3878` restituisce solo le 12 gare 2026/27 (4/11/2026 – 11/3/2027), nessuna giocata, e per
`3359` solo quelle del 2025/26. Cambiando `team_id` lo storico della stagione scorsa sparisce
da `/classifica`: è voluto, si lascia perdere la stagione passata. A inizio stagione la
classifica è tutta a zero (giocate 0): non è un errore di parsing.
Per ritrovare gli id a ogni cambio stagione: aprire `team_details.php?team_id=<nuovo id>` (il
`team_id` si legge dal link della squadra nella pagina del girone) oppure
`components/team-main.php?team_id=<id>`, che contiene una sezione "Campionati" con un link
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta (oggi solo
`933`). Se il CSI crea una Coppa e la squadra vi è iscritta, compare qui: si mette il suo id in
`CSI_COPPA_PROJECT_ID` e la sezione ricompare in `/classifica`.
### Endpoint usati dall'app
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| -------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `components/project-sheets.php?project_id=933` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=933` (tab "Classifica") |
| `components/project-sheets.php?project_id=<Coppa>` | HTML | Classifica del girone di Coppa, solo se `CSI_COPPA_PROJECT_ID` non è `null` (vedi limite 4) | `league_details.php?project_id=<Coppa>` (tab "Classifica") |
| `assets/json/getEventsByTeamId.php?team_id=3878` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3878` (tab "Calendario", `team-calendar.php`) |
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per il campionato
(`project_id=933`) e per la fase a gironi della Coppa, quando c'è: `parseClassifica()` è
condivisa, nessun parser dedicato per la Coppa.
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
Coppa insieme, vedi limite 4), con: `id`, `start` (`"2025-11-12T22:00:00"`, data+ora
locale), `team1`/`team2` (nomi squadre), `result` (`"3 - 1"`, stringa libera), `partials`
(`"25 - 23</br>23 - 25</br>..."`, HTML nei separatori), `field` (impianto), `project`
(nome campionato, es. `"PVM - Coppa CSI Misto Silver"` — usato per distinguere le
competizioni, vedi limite 4), `league`, `group` (es. `"Girone B"`), `match_number`. Letto
da `partiteDaEventi()` in `csi-core.ts`, che estrae punteggio/parziali con le regex
`punteggio()`/`parziali()` — vedi limite 5 sui rischi di questo parsing.
**Campi leggeri aggiuntivi letti dallo stesso JSON** (nessuna fetch in più, solo campi in
più letti dallo stesso `evento`): `team1_logo`/`team2_logo` (URL del logo, quello
dell'avversario finisce in `PartitaCsi.logoAvversario`), `group` (girone, es. `"Girone
B"`), `match_number` (n° gara, es. `"5/XEB"`), `referees` (arbitro, spesso vuoto), `link`
(URL del referto ufficiale, `match_details.php?id=…`).
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
`project-last_results.php`, `project-sheets-scorers.php`/`-results.php`/`-measures.php`
(sotto-tab di `project-sheets`: marcatori, risultati per giornata, provvedimenti
disciplinari), `team-roster.php` (rosa), `team-staff.php`, `team-results.php`,
`team-scorers.php`.
### Dettaglio di una singola gara (formazioni e scontri diretti)
Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `matchId` è lo
stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via
`assets/js/match.js`:
| Endpoint | Formato | Uso |
| -------------------------------------------- | ------- | ------------------------------------------------------------------- |
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale
punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro
`historyModal` in `match-stats.php` (un elenco partita-per-partita meno affidabile del
riepilogo aggregato — vedi sotto).
**`match-main.php`** — `parseInfoPartita()` (`csi-core.ts`) legge: la giornata (es. `"2ª
Giornata"`, assente per gare fuori dal girone come la finale di Coppa) e una nota libera
sotto l'impianto (`nota`). Quella nota **non ha un formato fisso**: a volte è `"Pubblico
non ammesso"`, a volte il nome della palestra, a volte altro — va mostrata così com'è, non
interpretata come un flag booleano.
**`match-players.php`** — `parseFormazioni()` legge due blocchi `<div class="col-12
col-md-6 mt-4">`, separati nel markup dal commento `<!-- SQUADRA OSPITE -->`, ciascuno con
una `<ul class="list-group">` di giocatori in ordine titolari → divisore "A DISPOSIZIONE"
→ panchina → divisore "STAFF" → staff (numero maglia, nome, ruolo; lo staff ha la stessa
struttura ma senza numero). Quale dei due blocchi sia "noi" si riconosce con
`isNostraSquadra()`, non assumendo un ordine fisso casa/ospite — verificato che l'ordine
nel markup è sempre "squadra casa" prima e "squadra ospite" dopo, ma il codice non si fida
di questo per evitare sorprese. `null` se nessuno dei due nomi è la nostra squadra
(referto non ancora compilato o formato cambiato).
**`match-stats.php`** — `parsePrecedenti()` legge il blocco di riepilogo in fondo alla
pagina (non la lista `historyModal` partita-per-partita, che in un caso osservato conteneva
gare di **altre squadre** senza relazione con la gara corrente — dato non affidabile da
interpretare): numero di precedenti, vittorie totali/in casa/fuori di entrambe, probabilità
di vittoria calcolata dal CSI. **Attenzione a un'insidia verificata sui dati reali**: le
due barre di probabilità sono colorate per chi è favorito (verde = più alta, rosso = più
bassa), **non** per casa/ospite — un parser ingenuo che associasse il verde alla squadra
casa sbaglierebbe metà delle volte. Il parser usa invece l'ordine di apparizione nel
markup (prima barra = squadra casa, seconda = ospite), coerente con l'ordine dei blocchi
"Squadra casa"/"Squadra ospite" più sopra nella stessa pagina. Con "0 precedenti" il CSI
omette del tutto le righe vittorie/in-casa/fuori (restano a `0`) ma la probabilità resta
comunque presente: `parsePrecedenti()` distingue quindi "0 precedenti" (oggetto valido con
`totale: 0`) da "formato non riconosciuto" (`null`, solo se nessuno dei due nomi squadra è
identificabile).
**Formato di `DettaglioPartitaCsi`** (il JSON che compongono insieme):
`{ giornata, nota, formazioni: { noi, avversario } | null, precedenti: PrecedentiCsi | null
}`, dove ogni `FormazioneSquadra` è `{ squadra, titolari: GiocatoreFormazione[], panchina,
staff: StaffFormazione[] }` e `GiocatoreFormazione` è `{ numero, nome, ruolo }`.
---
## Implementazione
```
CSI (portale)
↓ fetch server-side, cache 6 ore
/api/public/csi → src/routes/api/public/csi.ts
↓ JSON { classifica, classificaCoppa, partite, girone, aggiornato }
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
↓
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
sotto; tab "Storico partite": ogni squadra col proprio logo,
chevron di dettaglio sulle gare cliccabili, gare divise
per stagione; stagione anche nel sottotitolo dell'header)
CSI (portale, 3 endpoint)
↓ fetch server-side on-demand, cache per-partita 6 ore
/api/public/csi-partita/$id → src/routes/api/public/csi-partita.$id.ts
↓ JSON DettaglioPartitaCsi { giornata, nota, formazioni, precedenti }
useCsiPartita() → src/lib/csi-partita.ts (React Query, staleTime 6h)
↓
DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazioni + scontri diretti)
↓
/partita/$id (evento CrAPP collegato) o /partita-csi/$id (nessun evento collegato)
```
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe,
usata sia per `classifica` sia per `classificaCoppa`), `partiteDaEventi()` (JSON → partite),
`isNostraSquadra()`, `partiteGiocate()`, `matchDaPartitaCsi()` (porta i campi leggeri —
logo, girone, n° gara, arbitro, link — nella forma usata dalle liste), `parseInfoPartita()`,
`parseFormazioni()`, `parsePrecedenti()` (vedi sezione precedente).
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI per classifica e
partite: tre fetch in parallelo (classifica girone, classifica Coppa, partite). Cache in
memoria di 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne
esiste uno). Se solo la Coppa fallisce (`scarica(...).catch(() => "")`) la risposta resta
comunque `200` con `classificaCoppa: []`: è un dato supplementare, non blocca la classifica
del girone.
- **`src/routes/api/public/csi-partita.$id.ts`** — route separata per il dettaglio di una
singola gara: tre fetch in parallelo (`match-main`/`match-players`/`match-stats.php`). Cache
in memoria **per `matchId`** (una `Map`, non un singolo valore come `csi.ts`), stessa
finestra di 6 ore. A differenza di `/api/public/csi`, qui un fallimento del fetch è fatale
(`503`, nessun fallback "meglio un dato vecchio"): non c'è ancora una cache da riusare la
prima volta che qualcuno apre una gara, e un errore upstream reale (verificato: CSI risponde
`500` su `match-stats.php` per un `match_id` inventato) va distinto da "gara senza
formazioni ancora pubblicate" (quell'endpoint risponde `200` con markup vuoto, gestito da
`parseFormazioni()`/`parsePrecedenti()` restituendo `null`, non da un errore HTTP).
- **`src/lib/csi.ts`** / **`src/lib/csi-partita.ts`** — hook client React Query, stessa
`staleTime` di 6h. `useCsiPartita(matchId)` è `enabled` solo quando `matchId` è definito:
va montato solo nel dettaglio di una gara, mai in una lista (altrimenti sarebbe una fetch
per riga, vedi "Regole rispettate" sotto).
- **`src/components/crapp/DettaglioCsi.tsx`** — UI condivisa tra `/partita/$id` e
`/partita-csi/$id`: `LogoSquadra` (logo con hotlink diretto al portale CSI, si nasconde da
sola se l'immagine non carica invece di mostrare un'icona rotta), `MetaPartitaCsi` (girone,
n° gara, arbitro, link al referto — campi leggeri, zero fetch aggiuntive), `DettaglioCsiEsteso`
(formazioni + scontri diretti, monta `useCsiPartita()`).
- **`src/routes/partita-csi.$id.tsx`** — dettaglio "solo CSI" per le gare **senza** un evento
CrAPP collegato (l'app non crea ancora eventi automaticamente dal calendario CSI, vedi
"Evoluzioni possibili"): nessuna convocazione/presenza/MVP/scout, solo risultato, parziali
e i dati CSI di questa sezione. `id` è l'`id` della gara sul portale CSI
(`PartitaCsi.id`), non un evento CrAPP.
- **`src/routes/partita.$id.tsx`** — per le gare **con** un evento CrAPP collegato, mostra le
stesse informazioni CSI (logo, metadati, formazioni, scontri diretti) in più rispetto a
prima, quando `csiMatch` esiste per quella data.
- **`test/unit/csi-core.test.ts`** — check del parsing: `bun test/unit/csi-core.test.ts`.
Con `CSI_LIVE=1` verifica anche gli endpoint reali, incluse formazioni e precedenti di una
gara giocata.
### Regole rispettate
- **Nessuna chiamata dal browser per classifica/partite**: il portale viene contattato solo
lato server, al massimo 4 volte al giorno per `/api/public/csi`, indipendentemente da
quanti giocatori aprono l'app (regola anti-consumo). **`/api/public/csi-partita/$id` è
diverso di proposito**: è on-demand, chiamato solo quando un giocatore apre il dettaglio
di una gara specifica (mai precaricato in una lista, vedi `useCsiPartita()` sopra) — non
rientra nel limite delle 4 chiamate/giorno perché non è un dato mostrato a tutti a ogni
apertura dell'app, ma cache comunque 6 ore per evitare rifetch ripetuti sulla stessa gara.
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della
tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
ricadono sulle partite dello Scout Live che hanno un punteggio (`useScoutMatches()` filtrato con
`haPunteggio()`: quelle di sole valutazioni non sono un risultato).
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
---
## Limiti noti
1. **La classifica si legge da HTML.** Se il portale cambia la struttura della tabella il
parsing restituisce un array vuoto: `/classifica` non si rompe, ma mostra "Classifica non
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
sulle partite dello Scout Live con punteggio, non su dati demo — non esistono più in `crapp-data.ts`.
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
2. **`project_id` e `team_id` sono legati alla stagione.** Per il 2027/28 serviranno due nuovi
id (vedi "Sorgente dati" sopra per come ritrovarli): vanno aggiornati a mano in
`csi-core.ts`, e lo storico della stagione precedente sparisce (DD-032 non lo conserva).
L'etichetta «Stagione 2026/27» di `/classifica` invece non va toccata: il portale non
espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano
dalla data delle gare (l'anno cambia ad agosto) e segue da sola il nuovo `project_id`.
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di
`/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i
dati in una tabella Supabase riempita da un job cron (stesso pattern di
`promemoria-palloni`).
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
`getEventsByTeamId.php?team_id=3878` è per squadra, non per competizione (vedi tabella
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
**La classifica della Coppa, invece, è mostrata quando c'è** (sopra quella del girone in
`/classifica`; nel 2026/27 `CSI_COPPA_PROJECT_ID` è `null` e la sezione non compare). Nel
2025/26 era `project_id=848`: `project-sheets.php` ha la stessa
struttura a tabella-per-girone del campionato, quindi `parseClassifica()` funziona
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
`project-main.php?project_id=848` (2025/26) descrive il regolamento — "due gironi sola andata, le
due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra
sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il
tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
finale, `/classifica` continuerebbe a mostrare la classifica (ormai chiusa) del proprio
girone di Coppa, non l'esito della finale: non c'è codice che segua quel `project_id`
figlio, che oltretutto cambia a ogni edizione della Coppa e non è noto in anticipo.
5. **Le partite si leggono da JSON, con parsing fragile su campi testuali.** `result` e
`partials` in `getEventsByTeamId.php` sono stringhe libere tipo `"3-1"`, lette con
un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il portale CSI
cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la regex
non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata"
(`setNostri`/`setLoro` a `null`) — silenziosamente, senza errori. Se invece la risposta
cambiasse forma radicalmente (non più un array), `partiteDaEventi()` torna `[]`.
**Conseguenza sugli obiettivi di squadra**: le "vittorie in campionato" (`obiettivi.ts`,
obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite
si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali.
**Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme
(`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle
partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200`
con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di
`partiteDaEventi()` tramite `partiteFormatoSospetto()` (`csi-core.ts`): se ci sono eventi
grezzi ma nessuno è stato riconosciuto come nostra partita, logga un `console.error` —
distingue così un vero "formato cambiato" da un legittimo "nessuna gara ancora in
programma" (dove gli eventi grezzi stessi sono vuoti). Il flag `formatoSospetto` viaggia
anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
(`src/routes/classifica.tsx`), dove mostra un badge discreto ("Il portale CSI potrebbe aver
cambiato formato: dati da verificare.") al posto della normale riga "Dati CSI aggiornati
alle...": un log server passa inosservato per settimane, un badge visibile a chi apre la
pagina campionato molto meno. Il fix, quando succede, è isolato a
`partiteDaEventi()`/`punteggio()`/`parziali()` in `csi-core.ts` (gli endpoint stessi
cambiano solo se cambia il dominio o serve autenticazione, nel qual caso va toccata anche
`src/routes/api/public/csi.ts`); va poi aggiornato anche `test/unit/csi-core.test.ts` con
fixture nel nuovo formato.
6. **Il portale può essere del tutto irraggiungibile, non solo cambiare formato.** Scenario
diverso dal punto 5 (lì il JSON è valido ma non riconosciuto, qui la risposta non è
nemmeno JSON): l'8 settembre 2026 `getEventsByTeamId.php` ha risposto con `200` ma un
errore SQL del loro backend in chiaro al posto del JSON
(`Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist`,
verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
`503 "CSI non raggiungibile"` (o serve la cache se ce n'è una) — nessun crash, ma nessun
dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di
`test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una
volta prima di partire; se risponde con errore li salta (`salta()`, non `prova()`) invece
di farli fallire, loggando il motivo — la suite resta verde durante un'indisponibilità
temporanea del portale, senza che quei 5 test vengano cancellati o disattivati in modo
permanente: tornano a girare da soli non appena il CSI risponde di nuovo con `200`.
7. **I loghi delle squadre sono "hotlinked" direttamente dal browser al portale CSI**
(`LogoSquadra` in `DettaglioCsi.tsx` punta a `logoAvversario`, un URL
`livescore.csibologna.it/images/...`). È un'eccezione consapevole alla regola "nessuna
chiamata dal browser al CSI": un'immagine, a differenza dei dati, non ha bisogno di
passare dalla cache server per restare aggiornata, e proxarla/cacherla lato server per
ogni squadra avversaria (potenzialmente decine a stagione) sarebbe uno sforzo sproporzionato
al beneficio. Se un logo non carica (URL cambiato, squadra senza foto),
`LogoSquadra` si nasconde da sola (`onError` → `null`) invece di mostrare un'icona rotta.
8. **L'ordine "titolari"/"A disposizione" in `parseFormazioni()` è quello del referto CSI,
non necessariamente il sestetto che è sceso davvero in campo al fischio d'inizio.** Il
CSI non separa esplicitamente "chi ha giocato titolare" da "chi era comunque convocato e
in lista gara": il divisore "A DISPOSIZIONE" nella pagina sembra riflettere l'ordine di
inserimento nel referto più che le sostituzioni reali. Va quindi presentato come "referto
del CSI", non come cronaca esatta di chi ha giocato quanto.
---
## Evoluzioni possibili
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
- Creazione automatica degli eventi partita da calendario CSI — risolverebbe anche il
limite 4 di sopra: ogni gara avrebbe un evento CrAPP e andrebbe sempre su `/partita/$id`,
senza più bisogno di `/partita-csi/$id` per le gare "orfane".
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
- Tabellone a eliminazione della fase finale di Coppa (limite 4): oggi non tracciato, il
`project_id` figlio (es. `905`) andrebbe scoperto a runtime leggendo il link dentro
`project-sheets.php?project_id=848` invece di essere una costante.
-59
View File
@@ -1,59 +0,0 @@
# Modulo — Infortuni
**Stato:** implementato in forma minima (solo conteggio)
**File principali:** `src/lib/infortuni.ts`
---
## Obiettivo
Tracciare quanti eventi (allenamenti o partite) un giocatore ha saltato per infortunio,
riusando lo stato di presenza `infortunato` già registrato per le convocazioni — nessun
modulo di gestione infortuni a sé stante.
---
## Dati
Nessuna tabella dedicata: il dato vive interamente dentro `risposte_presenze`, come uno dei
valori possibili dell'enum `Stato` (`presente`, `assente`, `forse`, `ritardo`, `infortunato`).
---
## Implementazione
`contaStato()`/`contaInfortuni()` (`infortuni.ts`) contano, per ciascun giocatore, quante
volte compare lo stato `infortunato` nella mappa presenze già in cache (nessuna query
aggiuntiva). Lo stesso meccanismo, con `contaRitardi()`, conta i ritardi. Il risultato
alimenta il campo `infortuni` del `Giocatore` in `useRosa()`.
Un evento con data futura o odierna non viene contato, anche se la risposta è già registrata
(l'UI permette di segnarsi infortunato o in ritardo su un evento non ancora passato): il
conteggio filtra su `data < oggi`, come già fa `eventiContanoPresenze()` in
[presenze.md](presenze.md), e cresce da solo con l'avanzare della data reale senza bisogno di
altro codice.
Visibile in UI solo indirettamente, tramite il [badge](badge.md) segreto "Cliente VIP
dell'Infermeria" (sbloccato con almeno 3 infortuni): non esiste uno StatTile dedicato nel
profilo che mostri il numero di infortuni come statistica di superficie.
---
## Limiti noti
- Nessuna durata o periodo tracciato: è solo un conteggio di eventi con quello stato, non un
inizio/fine infortunio.
- Il conteggio dipende dal fatto che qualcuno imposti correttamente lo stato "infortunato"
invece di "assente": nessuna validazione o promemoria lo garantisce. Sulle card degli
eventi extra-campo (`tipo: "evento"`) l'opzione non è proposta in UI.
- Poco visibile per valori bassi (1-2), perché emerge solo tramite un badge a soglia 3.
- `conInfortuni()`, una funzione di merge alternativa nello stesso file, non risulta usata da
nessuna parte del codice attuale — probabile residuo non collegato.
---
## Evoluzioni possibili
- Uno StatTile dedicato nel profilo, oltre al badge segreto.
- Se servisse un vero tracciamento (durata, tipo di infortunio), servirebbe una tabella
dedicata: oggi il modulo copre solo il conteggio.
-89
View File
@@ -1,89 +0,0 @@
# Modulo — Votazione MVP
**Stato:** implementato
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
**Test:** `test/unit/mvp-voti.test.ts`, `test/integration/permessi.test.ts` (regole del voto), `mvp-badge`, `s-tiebreak-badge`
---
## Obiettivo
Eleggere il MVP di una partita tramite voto tra compagni, un voto a testa, con vincitore
calcolato a runtime.
---
## Dati
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
partita, **definitivo** (DD-051) e **segreto** (DD-052): la tabella la legge solo chi ha votato (il
proprio voto) e gli admin; la squadra legge i conteggi dalla vista `mvp_voti_anonimi` (`match_id`,
`votato_id`, `votato_nome`, senza `votante_id`). `votato_nome` lo riempie un trigger dalla rosa a
partire da `votato_id`: il nome inviato dal client è ignorato.
`match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la
votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI
restano nel database ma non vengono più letti da nessuna schermata).
---
## Implementazione
- Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()`
lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo
quando aprirà; `votoMvpChiuso()` lo chiude `DURATA_VOTO_MVP_ORE` (24) ore dopo l'apertura
(DD-052). A votazione chiusa il pannello mostra «Votazione chiusa» e i risultati, senza il
pulsante «Vota». Nessun legame con il risultato caricato.
- Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in
`usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco.
- `useVotiMvp()` legge i conteggi dalla vista `mvp_voti_anonimi`; `useMieiVotiMvp()` legge dalla
tabella solo i voti dell'utente (gli unici che la policy gli lascia), per «Hai votato X».
- `useVotaMvp()` fa un `INSERT`: il voto è definitivo. Il pannello chiede di scegliere un nome e di
confermarlo, poi mostra «Hai votato X: il voto non si può cambiare». A database (`m35`, DD-051) il
giocatore ha solo la policy di `INSERT`: modificare o cancellare un voto è possibile solo agli admin.
- Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo
`mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
- `mvpPartita()` restituisce i più votati: **in caso di parità in testa sono tutti MVP a pari merito**
(DD-051). `vincitoriMvp()` ne unisce i nomi («Anna e Bruno») per Home e Classifica.
- `mvpPartita()` (quindi anche `vincitoriMvp()` e `mvpVintiPerGiocatore()`) richiede un quorum minimo di voti totali
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
incoronare nessuno, nemmeno senza concorrenza.
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita in cui il giocatore è tra i più votati
(anche a pari merito); il risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
nel profilo e in home.
---
## Limiti noti
- Il voto si chiude 24 ore dopo l'apertura (DD-052). Le partite più vecchie sono quindi chiuse:
chi non aveva votato non può più farlo. Gli admin possono ancora scrivere un voto dal database.
- Gli admin leggono la tabella `mvp_voti` (gli serve per correggere un voto), quindi a livello di
database vedono chi ha votato chi; l'app non lo mostra a nessuno.
- I conteggi sono visibili anche durante la votazione. Nascondere i risultati fino alla chiusura è una
possibile evoluzione.
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
= tutta la rosa). Da `m36` (DD-051) la policy usa `voto_mvp_consentito()`, che aggiunge a quei
controlli: l'evento è una **partita**, votante e votato hanno risposto `presente` o `ritardo` e
sono passate **2 ore** dall'inizio e **non ancora altre 24** (`Europe/Rome`; un `ora` vuoto
vale mezzanotte). Sono le stesse
regole del pannello, ora imposte anche a chi scrive direttamente su PostgREST; l'attesa di 2 ore
e la chiusura stanno in `ORE_ATTESA_MVP`/`DURATA_VOTO_MVP_ORE` e negli intervalli della funzione
(`m37`); un test unitario li tiene allineati. Gli
admin restano fuori da questi vincoli (possono correggere un voto). La presenza si controlla solo
al momento del voto: se qualcuno cambia risposta dopo, il voto già dato resta.
- Sotto il quorum minimo di voti nessun MVP viene assegnato per quella partita. Una parità in testa,
invece, assegna l'MVP a tutti i pari merito.
- Un voto sbagliato si corregge solo da un admin (dal database): non c'è un'interfaccia per farlo.
---
## Evoluzioni possibili
- Nascondere i risultati finché la votazione non è chiusa.
- Una schermata per gli admin per correggere un voto (oggi solo dal database).
Loaded 100 of 472 files, more files were not shown because too many files have changed in this diff. Show more