Compare commits
264
Commits
3fdbfefa62
..
v1.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49351e536 | ||
|
|
008f0d784c | ||
|
|
683eb87320 | ||
|
|
f19f0bbf8d | ||
|
|
de78431090 | ||
|
|
e822e4574b | ||
|
|
cdf8b144e2 | ||
|
|
4b5ffc6664 | ||
|
|
8f25143aa5 | ||
|
|
c12011d330 | ||
|
|
7143c3d2eb | ||
|
|
71b2d97fae | ||
|
|
9b4e3e4b45 | ||
|
|
563142a30a | ||
|
|
07624f6b9f | ||
|
|
ded71f567b | ||
|
|
b6dee87eb3 | ||
|
|
4760e8650c | ||
|
|
e9013b703b | ||
|
|
58eba642f7 | ||
|
|
969dc1400c | ||
|
|
b7d3fde225 | ||
|
|
2a0b611459 | ||
|
|
cf667453a7 | ||
|
|
39918f2eb7 | ||
|
|
a72eb087d1 | ||
|
|
34edfc662e | ||
|
|
e0a9d19a02 | ||
|
|
3c2f9cf876 | ||
|
|
93d70f4879 | ||
|
|
20ca6ba5a6 | ||
|
|
342b457ee7 | ||
|
|
a6defc1a49 | ||
|
|
0a2112a9f3 | ||
|
|
a51cdb43e4 | ||
|
|
49d4d9de31 | ||
|
|
9cc4c7e47a | ||
|
|
46bcb21cdb | ||
|
|
15a23dfaf4 | ||
|
|
2ec72fd08f | ||
|
|
15a010855e | ||
|
|
dda4aff746 | ||
|
|
e1e4ca0df6 | ||
|
|
ae5d790011 | ||
|
|
33ed2398ab | ||
|
|
c6437a605c | ||
|
|
431530af2a | ||
|
|
61d4dacb3a | ||
|
|
cdd433cb15 | ||
|
|
3f2ca30775 | ||
|
|
4d036f532d | ||
|
|
0314560885 | ||
|
|
0286961e92 | ||
|
|
6372ef9c8d | ||
|
|
fcff950006 | ||
|
|
99ca3b6495 | ||
|
|
457a0fe23d | ||
|
|
d8fc6ef04f | ||
|
|
e99d853987 | ||
|
|
efca4243b6 | ||
|
|
abd6fafc0c | ||
|
|
d52ceeb0f9 | ||
|
|
a9f146d77e | ||
|
|
04e80a3531 | ||
|
|
5569d08b59 | ||
|
|
c628997934 | ||
|
|
8353761b6e | ||
|
|
15f058f760 | ||
|
|
8dfd81ebe4 | ||
|
|
bef5542ce3 | ||
|
|
57463f993b | ||
|
|
c7cd527045 | ||
|
|
00235d1594 | ||
|
|
6d177d5d8a | ||
|
|
89b08349c4 | ||
|
|
f1d61ebd6e | ||
|
|
dbf60d3a7e | ||
|
|
b0c1e2f826 | ||
|
|
0eb2d60569 | ||
|
|
3b305b0f8e | ||
|
|
a451d4187f | ||
|
|
8a692bfe2f | ||
|
|
cde01ff02b | ||
|
|
d250f41749 | ||
|
|
4fb05748c3 | ||
|
|
10d1455eff | ||
|
|
e6a222f196 | ||
|
|
1fdc6720a0 | ||
|
|
080379cc64 | ||
|
|
8c9cf370e8 | ||
|
|
c7588c5aa2 | ||
|
|
9b81fcf2fc | ||
|
|
242941740b | ||
|
|
7e51fe6345 | ||
|
|
8c6fceb682 | ||
|
|
0dadabfea6 | ||
|
|
261578357a | ||
|
|
447016f03b | ||
|
|
e13722eae9 | ||
|
|
2b30dd4708 | ||
|
|
440bf0cd56 | ||
|
|
8add483a5b | ||
|
|
3258a24f20 | ||
|
|
82b6c6b333 | ||
|
|
08b00627e5 | ||
|
|
fc65914f55 | ||
|
|
8e23f31685 | ||
|
|
f5aaa1cccb | ||
|
|
b4878b7795 | ||
|
|
9cc63b7854 | ||
|
|
e7b5082398 | ||
|
|
446403cdf2 | ||
|
|
a7ae9ed3a4 | ||
|
|
ed289b9ecd | ||
|
|
b92a167b5a | ||
|
|
a06c5ed281 | ||
|
|
10915d04fc | ||
|
|
a459579f8b | ||
|
|
a6799b91d3 | ||
|
|
c44fd8495e | ||
|
|
7ef7eef963 | ||
|
|
8cd0f1dc88 | ||
|
|
b26d382c11 | ||
|
|
9e2f83a85e | ||
|
|
a1d2599c47 | ||
|
|
ca1eee5425 | ||
|
|
6bc5e8f006 | ||
|
|
ea9f17e8d2 | ||
|
|
7e65df1d21 | ||
|
|
f149d02e9a | ||
|
|
da669db1eb | ||
|
|
7c1f08d943 | ||
|
|
f687322c3f | ||
|
|
752b300474 | ||
|
|
3d8cb22235 | ||
|
|
fb6bde279e | ||
|
|
5c709c1a12 | ||
|
|
78d41daf4f | ||
|
|
8047e58ae0 | ||
|
|
d8f334007b | ||
|
|
2b8ba340a6 | ||
|
|
ee9f3f1b8c | ||
|
|
847972b582 | ||
|
|
93b61d422a | ||
|
|
fe0c938358 | ||
|
|
0addafa76f | ||
|
|
94673090f1 | ||
|
|
50a712c276 | ||
|
|
77f587b53b | ||
|
|
ee21278e82 | ||
|
|
d5035cbf55 | ||
|
|
a8bde94153 | ||
|
|
41ed3a1f0f | ||
|
|
208dd5f2f6 | ||
|
|
d4a02fb606 | ||
|
|
90f16403fb | ||
|
|
0c136f8f53 | ||
|
|
73902cc0a4 | ||
|
|
a7d0f5af5b | ||
|
|
2a1a0839c0 | ||
|
|
107407b854 | ||
|
|
366928b61f | ||
|
|
84c4c0fc25 | ||
|
|
a5e627a57b | ||
|
|
46f5c28237 | ||
|
|
1e8168d413 | ||
|
|
61c6aaaaed | ||
|
|
033c45df4d | ||
|
|
2345a45f14 | ||
|
|
ce1c7cfd4b | ||
|
|
e9755d364c | ||
|
|
f4398fce25 | ||
|
|
ede02b7575 | ||
|
|
c089d9e8ba | ||
|
|
bb4fb624f5 | ||
|
|
37a04e5103 | ||
|
|
383643392a | ||
|
|
fabee4df1d | ||
|
|
c8cd8c9720 | ||
|
|
6e05f5134f | ||
|
|
1685d0b57a | ||
|
|
608f2eda2f | ||
|
|
78ae3fbbd7 | ||
|
|
5a534547ae | ||
|
|
26bacbf700 | ||
|
|
9b28a86f7c | ||
|
|
5c49294ad1 | ||
|
|
1ff650ecd5 | ||
|
|
e7863ab950 | ||
|
|
18c6205e06 | ||
|
|
230ea1d156 | ||
|
|
38e0185325 | ||
|
|
7433391c30 | ||
|
|
822180bffc | ||
|
|
7237e8ff39 | ||
|
|
952f7b43d3 | ||
|
|
438076c572 | ||
|
|
fe1ac5434c | ||
|
|
9aef361bf0 | ||
|
|
f70430e1bf | ||
|
|
72a9864e94 | ||
|
|
2db2086c10 | ||
|
|
ac4a4a96e8 | ||
|
|
6286c18a72 | ||
|
|
70b4123452 | ||
|
|
0c2a6c5330 | ||
|
|
5c9ffb84e8 | ||
|
|
988eb6d575 | ||
|
|
0710d143a9 | ||
|
|
9c173df753 | ||
|
|
a07c8104d5 | ||
|
|
224bb93beb | ||
|
|
923d1fe762 | ||
|
|
4c0ea66126 | ||
|
|
b6c0f40f66 | ||
|
|
7e93229eee | ||
|
|
4470139504 | ||
|
|
cce6525f09 | ||
|
|
c7445cfab1 | ||
|
|
ff45b2379c | ||
|
|
d3b117e470 | ||
|
|
64771fffc6 | ||
|
|
395cee3c9c | ||
|
|
5e4ad307c9 | ||
|
|
c52e4c4e27 | ||
|
|
d63beae04c | ||
|
|
c314a08f6d | ||
|
|
d2d62b6799 | ||
|
|
f6036f21ec | ||
|
|
1a93c7657a | ||
|
|
85223baa9d | ||
|
|
0a04025fe9 | ||
|
|
d813dee282 | ||
|
|
13e5c3bd23 | ||
|
|
215af4bbc4 | ||
|
|
127e7c76e9 | ||
|
|
aa1c49542b | ||
|
|
1036eb860d | ||
|
|
f325c0485c | ||
|
|
5a6aaad885 | ||
|
|
e4f963d170 | ||
|
|
f424b3a9f7 | ||
|
|
990c2bbd4c | ||
|
|
764f75085e | ||
|
|
9e17c2fadd | ||
|
|
53b2252997 | ||
|
|
718ef09dfa | ||
|
|
3f52e9cc54 | ||
|
|
255afde48e | ||
|
|
da51517ffc | ||
|
|
ea29f053e3 | ||
|
|
e1e8dd5415 | ||
|
|
c06b33e83b | ||
|
|
9ca124a868 | ||
|
|
af563603f9 | ||
|
|
fee3d0b55a | ||
|
|
baaffc66db | ||
|
|
7d16bdba75 | ||
|
|
7b4bfe8b27 | ||
|
|
32782227f4 | ||
|
|
57b36b5a09 | ||
|
|
19e1bb0790 | ||
|
|
7a2b26ec89 | ||
|
|
6dc63d9250 |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"vercel@claude-plugins-official": true,
|
||||
"supabase@claude-plugins-official": true,
|
||||
"claude-md-management@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
---
|
||||
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)` |
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
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`.
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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
|
||||
@@ -0,0 +1,70 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,55 @@
|
||||
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
|
||||
+11
-3
@@ -39,7 +39,15 @@ dist-ssr
|
||||
|
||||
# Optional
|
||||
.vercel
|
||||
# Supabase CLI local state
|
||||
supabase/.temp/
|
||||
|
||||
# Supabase self-host: dati runtime, mai versionati
|
||||
infra/supabase/docker/volumes/db/data/
|
||||
infra/supabase/docker/volumes/storage/
|
||||
# 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/
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,58 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,34 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"template": "tanstack_start_ts_current",
|
||||
"revision": "tanstack_start_ts_current-9e5645c506e5"
|
||||
}
|
||||
@@ -1,10 +1,152 @@
|
||||
<!-- 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 -->
|
||||
# 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.
|
||||
@@ -1,129 +1,9 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
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).
|
||||
|
||||
## 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.
|
||||
**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.
|
||||
@@ -0,0 +1,14 @@
|
||||
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.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 01/10/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.2.3 (in produzione su `main` la 1.2.2) — notifiche su tre canali, avvisi di palloni
|
||||
(3 ore prima) e presenze (24 ore prima) una sola volta, niente promemoria evento, niente foto
|
||||
tessera, avviso certificato a 30 giorni per il giocatore, avviso rosso per chi non l'ha caricato.
|
||||
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
|
||||
Project Ref `hetycilxgkdmccelwerq` non si usano più.
|
||||
- **Migration:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte
|
||||
applicate in produzione (verificato con `npx supabase migration list` il 01/10/2026). La `m29` toglie
|
||||
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
|
||||
- **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 `develop`, non ancora su `main`.
|
||||
- **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:** niente di assegnato. Le voci aperte stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
|
||||
## 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» è impostato come allenatore in produzione (24/09/2026). Numero 99
|
||||
e ruolo «Palleggiatore» sono rimasti apposta per la 0.9.2: da svuotare da `/admin` se ancora
|
||||
presenti.
|
||||
|
||||
## Note
|
||||
|
||||
Il progetto segue una metodologia document-first (DD-002): ogni funzionalità si progetta in
|
||||
`docs/modules/` prima di essere implementata.
|
||||
@@ -1,191 +1,59 @@
|
||||
# CrAPP — CRAP Volley Hub
|
||||
# CrAPP 🏐
|
||||
|
||||
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.
|
||||
CrAPP è una Progressive Web App sviluppata per digitalizzare completamente la gestione di una squadra di pallavolo.
|
||||
|
||||
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).
|
||||
## Funzionalità principali
|
||||
|
||||
**Live app**: https://volley-cronos-app.lovable.app
|
||||
- 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)
|
||||
|
||||
## Stack
|
||||
## Stack tecnologico
|
||||
|
||||
- [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
|
||||
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).
|
||||
|
||||
## Funzionalità
|
||||
## Avvio locale
|
||||
|
||||
- **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
|
||||
Le dipendenze si installano con **bun** (`bun.lock`):
|
||||
|
||||
## Sviluppo (locale)
|
||||
|
||||
Package manager: **bun** ([installazione](https://bun.sh)).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/ivancacciari1995-a11y/CRAPP.git
|
||||
cd CRAPP
|
||||
```bash
|
||||
bun install
|
||||
npm run dev # http://localhost:8080
|
||||
```
|
||||
|
||||
### 1. Avvia un backend Supabase
|
||||
## Comandi
|
||||
|
||||
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.
|
||||
```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
|
||||
```
|
||||
|
||||
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):
|
||||
Chi aggiunge o modifica una funzione scrive anche il test e lo lascia verde
|
||||
([test/README.md](test/README.md)).
|
||||
|
||||
```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
|
||||
|
||||
Applica le migrazioni del progetto al database appena creato:
|
||||
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).
|
||||
|
||||
```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
|
||||
```
|
||||
## Variabili d'ambiente
|
||||
|
||||
### 2. Configura `.env` di CrAPP
|
||||
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).
|
||||
|
||||
Nella root del repo, crea `.env` con le chiavi lette da `infra/supabase/docker/.env`:
|
||||
## Documentazione
|
||||
|
||||
```
|
||||
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.
|
||||
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).
|
||||
@@ -0,0 +1,132 @@
|
||||
# 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` |
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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.");
|
||||
@@ -0,0 +1,30 @@
|
||||
/** 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;
|
||||
}
|
||||
@@ -3,36 +3,8 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tanstack_start_ts",
|
||||
"name": "crapp",
|
||||
"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",
|
||||
@@ -40,30 +12,21 @@
|
||||
"@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",
|
||||
"@lovable.dev/vite-tanstack-config": "2.8.5",
|
||||
"@tanstack/devtools-vite": "^0.8.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^19.2.0",
|
||||
@@ -116,16 +79,12 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -150,16 +109,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -180,14 +129,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -242,112 +183,38 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -380,8 +247,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -396,8 +261,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -490,24 +353,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -570,16 +415,12 @@
|
||||
|
||||
"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@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -598,38 +439,10 @@
|
||||
|
||||
"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=="],
|
||||
@@ -638,16 +451,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -680,16 +485,12 @@
|
||||
|
||||
"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=="],
|
||||
@@ -706,6 +507,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -738,10 +541,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -798,12 +597,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -812,6 +607,12 @@
|
||||
|
||||
"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=="],
|
||||
@@ -824,8 +625,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -860,40 +659,22 @@
|
||||
|
||||
"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=="],
|
||||
@@ -934,8 +715,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -944,8 +723,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -974,8 +751,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1016,10 +791,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -1036,14 +807,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
@@ -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 = ["@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"]
|
||||
minimumReleaseAgeExcludes = []
|
||||
@@ -0,0 +1,177 @@
|
||||
# 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`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
|
||||
schermate, pochi click.
|
||||
|
||||
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)).
|
||||
@@ -0,0 +1,316 @@
|
||||
# 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.2.3] - 2026-10-04
|
||||
|
||||
Nella Home si vede anche chi non ha caricato il certificato medico. 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).
|
||||
|
||||
## [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.
|
||||
@@ -0,0 +1,167 @@
|
||||
# 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 |
|
||||
| `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) |
|
||||
| `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` | Chi è già tesserato al CSI. Come `numero` e `ruolo` le scrive solo un admin: il trigger le include 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. 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: confrontato con `eventi_app.creato_il` dà la serie «Conferme 24h». `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). | Letta/scritta da `src/lib/scout-store.ts`. |
|
||||
|
||||
## 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`. |
|
||||
| `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`. |
|
||||
|
||||
## 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 `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 ma non quelli dei pulsanti.
|
||||
|
||||
### 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) |
|
||||
| `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).
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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, partite, conferme entro 24 ore). 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 delle azioni di una partita, 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/`. |
|
||||
@@ -0,0 +1,106 @@
|
||||
# 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.
|
||||
+19
-10
@@ -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. |
|
||||
| `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. |
|
||||
| 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. |
|
||||
|
||||
## Regole da rispettare nelle prossime modifiche
|
||||
|
||||
@@ -28,6 +28,15 @@ 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.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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, export CSV e tracciamento di chi è già
|
||||
tesserato (numero e data di tessera)
|
||||
- [x] Collegamento CSI (stagione 2025/26)
|
||||
- [x] Classifica automatica (campionato e Coppa)
|
||||
- [x] Risultati campionato
|
||||
- [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)
|
||||
|
||||
## Prossimo
|
||||
|
||||
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
||||
Campionato usa solo quelle giocate
|
||||
- [ ] Database esercizi
|
||||
- [ ] AI Allenamenti
|
||||
- [ ] Archivio allenamenti
|
||||
|
||||
## Idee future
|
||||
|
||||
- [ ] Gestione quote
|
||||
- [ ] Calendario Google
|
||||
- [ ] Backup automatici
|
||||
- [ ] Analisi statistiche avanzate
|
||||
- [ ] Widget meteo
|
||||
- [ ] Analisi Scout con AI
|
||||
@@ -0,0 +1,246 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,21 @@
|
||||
### 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]
|
||||
@@ -0,0 +1,8 @@
|
||||
# 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. |
|
||||
@@ -0,0 +1,89 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 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)
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
|
||||
**Riesame**
|
||||
Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,71 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,90 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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()`.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,76 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,94 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,106 @@
|
||||
# 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-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 |
|
||||
@@ -0,0 +1,211 @@
|
||||
# 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 💩» |
|
||||
| `/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. |
|
||||
| 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.
|
||||
@@ -0,0 +1,246 @@
|
||||
# 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 5 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 |
|
||||
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
|
||||
|
||||
### 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-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
|
||||
| `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 | `serie-conferme` | normale | ✅ (vedi Limiti noti) | ✅ (`serie-conferme-badge`) |
|
||||
| 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
|
||||
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) |
|
||||
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
||||
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
||||
| 11 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
|
||||
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
||||
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
|
||||
| 16 | `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)**: i badge "Sempre in palestra",
|
||||
"Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le
|
||||
serie sono calcolate sui dati reali dalla migration `m9` in avanti, ma "Risposta lampo" e
|
||||
"Mai un forfait" dipendono da `serieConferme`, e `risposto_il` non è ricostruibile per le
|
||||
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
|
||||
- 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 16 badge)**: `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 i tre badge legati alle 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.
|
||||
@@ -0,0 +1,80 @@
|
||||
# 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.
|
||||
- **`/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`.
|
||||
|
||||
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".
|
||||
@@ -0,0 +1,349 @@
|
||||
# Modulo — Collegamento CSI
|
||||
|
||||
**Stato:** implementato (stagione 2025/26)
|
||||
**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 2025/26)
|
||||
|
||||
| Cosa | Valore |
|
||||
| --------------------- | -------------------------------------- |
|
||||
| Campionato | PVM - Campionato Open Misto Eccellenza |
|
||||
| `project_id` (girone) | `767` |
|
||||
| Coppa | PVM Coppa CSI Misto Silver |
|
||||
| `project_id` (coppa) | `848` |
|
||||
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
|
||||
| `team_id` | `3359` |
|
||||
| Girone | B |
|
||||
|
||||
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
|
||||
`src/lib/csi-core.ts`.
|
||||
|
||||
**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora
|
||||
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono
|
||||
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica`
|
||||
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel
|
||||
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
|
||||
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
|
||||
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
|
||||
a 2026/27 ([DD-032](../decisions/DD-032.md)).
|
||||
|
||||
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
|
||||
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
|
||||
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
|
||||
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
|
||||
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire
|
||||
`team_details.php` nel browser, questo componente basta.
|
||||
|
||||
### Endpoint usati dall'app
|
||||
|
||||
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
|
||||
| ------------------------------------------------ | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
|
||||
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
|
||||
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (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 `project_id=767`
|
||||
(campionato) e `project_id=848` (fase a gironi della Coppa): `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 locale (`useScoutMatches()`).
|
||||
`/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 locale, 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` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
|
||||
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
|
||||
L'etichetta «Stagione 2025/26» 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=3359` è 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** (sopra quella del girone in
|
||||
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
|
||||
struttura a tabella-per-girone di `project_id=767`, 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` 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.
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Modulo — Votazione MVP
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 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, sovrascrivibile.
|
||||
|
||||
`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à. 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.
|
||||
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
|
||||
limiti, senza storico.
|
||||
- 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.
|
||||
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
|
||||
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
|
||||
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche 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 "vinta" con margine netto; il
|
||||
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
||||
nel profilo e in home.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
|
||||
- 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): prima era un filtro solo applicativo, ora un giocatore non convocato non
|
||||
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
|
||||
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
|
||||
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
|
||||
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
|
||||
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
|
||||
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella
|
||||
partita.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita).
|
||||
@@ -0,0 +1,243 @@
|
||||
# Modulo — Notifiche email
|
||||
|
||||
**Stato:** implementato e in produzione dal 29/09/2026 (migration `m22`, worker in `mailer/`)
|
||||
**File principali:** `supabase/migrations/20260929120000_m22_notifiche_email.sql`,
|
||||
`mailer/mailer.mjs`, `mailer/mailer-core.ts`, `mailer/healthcheck.mjs`, `mailer/Dockerfile`,
|
||||
`mailer/docker-compose.yml`, `src/lib/preferenze-utente.ts`, `src/routes/profilo.tsx`
|
||||
(interruttore «Email»)
|
||||
|
||||
Questo documento descrive **cosa fa** il canale email. Come si installa, si configura e si
|
||||
diagnostica il worker sta in [WORKER_EMAIL.md](../WORKER_EMAIL.md); le motivazioni delle scelte in
|
||||
[DD-036](../decisions/DD-036.md).
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore si è
|
||||
registrato, **a costo zero**. È un terzo canale accanto alla push e al centro notifiche in-app
|
||||
([notifiche.md](notifiche.md)): non li sostituisce e non introduce eventi nuovi. Manda per email
|
||||
**ogni notifica che finisce già in `notifiche_utente`**, di qualunque tipo e per qualunque
|
||||
destinatario.
|
||||
|
||||
## Architettura
|
||||
|
||||
```
|
||||
App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ notifiche_email_coda (Supabase)
|
||||
▲
|
||||
polling in uscita │ RPC con service role
|
||||
│
|
||||
Container Docker sull'host di casa (mailer)
|
||||
│ SMTP 587 (STARTTLS)
|
||||
▼
|
||||
smtp.gmail.com ──▶ casella Gmail del giocatore
|
||||
```
|
||||
|
||||
- **Vercel non ospita Docker**, quindi il worker gira su un host proprio e sta **solo in uscita**:
|
||||
interroga Supabase e parla con Gmail. Nessuna porta aperta sul router, nessun IP pubblico,
|
||||
nessun tunnel.
|
||||
- **L'app non invia mai email direttamente.** Continua a scrivere `notifiche_utente` come prima:
|
||||
le route e i cron di M17 non sono stati toccati. Se l'host è spento le mail restano in coda e
|
||||
partono alla ripartenza: consegna in ritardo, mai persa.
|
||||
- **L'accodamento sta nel database** (un trigger), quindi vale per ogni sorgente, cron `pg_cron`
|
||||
compresi.
|
||||
- Il worker gira con **bun** (immagine `oven/bun:1-alpine`, come il resto del progetto) e ha una
|
||||
sola dipendenza, `nodemailer`. Parla con Supabase via `fetch` sulle funzioni RPC di PostgREST,
|
||||
senza `@supabase/supabase-js`. Ha un proprio `package.json` e `bun.lock`, separati da quelli
|
||||
dell'app, che non guadagna nessuna dipendenza.
|
||||
- La chiave con cui il worker si autentica può essere sia una chiave JWT sia una chiave nel formato
|
||||
nuovo `sb_secret_…`: viene inviata in `apikey` e in `Authorization: Bearer` e Supabase accetta
|
||||
entrambe (verificato sul progetto di produzione).
|
||||
|
||||
## Quali notifiche e a chi
|
||||
|
||||
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla
|
||||
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
|
||||
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
|
||||
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
|
||||
**non** vengono inviate: il trigger non ha backfill.
|
||||
|
||||
Il [catalogo delle notifiche](notifiche.md#catalogo-delle-notifiche) elenca cosa fa partire ciascuna
|
||||
e i suoi canali: tutte le notifiche generate dal server passano da `notifiche_utente` e quindi
|
||||
arrivano anche per email (DD-038).
|
||||
|
||||
**Chi le riceve:** chi ha la riga in `notifiche_utente`, con la stessa logica delle notifiche
|
||||
in-app. Gli admin non hanno un percorso a parte: ricevono ciò che riceverebbero secondo il proprio
|
||||
slot. L'allenatore riceve solo i messaggi dello staff, non i promemoria degli eventi (DD-039), né il
|
||||
sollecito, il turno palloni e il sondaggio (DD-034, DD-038).
|
||||
|
||||
Il certificato in scadenza **non genera mail**: non è una notifica ma una card calcolata in Home
|
||||
(DD-035). Se un giorno lo si volesse anche per email sarebbe una sorgente nuova di
|
||||
`notifiche_utente`, da decidere a parte.
|
||||
|
||||
**Indirizzo:** `giocatori_squadra.email`, la stessa colonna che collega account e giocatore
|
||||
(DD-018). Uno slot senza `auth_user_id` (giocatore non ancora registrato), senza email o il cui
|
||||
account ha spento le email non riceve nulla: la riga in coda passa a `saltata`, senza errore.
|
||||
|
||||
## Mittente
|
||||
|
||||
Il worker invia con un **account Gmail dedicato**, tramite `smtp.gmail.com:587`, autenticandosi con
|
||||
una **password per app**. Come crearla e i problemi noti stanno in
|
||||
[WORKER_EMAIL.md](../WORKER_EMAIL.md#account-gmail-e-password-per-app).
|
||||
|
||||
Perché Gmail e non un relay come Brevo o Resend: i destinatari sono già su Gmail, quindi la mail
|
||||
parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza comprare né
|
||||
configurare un dominio. Gmail riscrive l'intestazione `From` con l'indirizzo dell'account: il nome
|
||||
visibile è «CrAPP», l'indirizzo no. Il tetto è di circa 500 destinatari al giorno per un account
|
||||
gratuito (valore da verificare); il worker si ferma a `MAIL_LIMITE_GIORNO`. **Non si invia mai
|
||||
direttamente dal proprio IP di casa**: gli IP residenziali sono nelle blocklist e senza reverse DNS
|
||||
le mail finiscono in spam o vengono rifiutate.
|
||||
|
||||
## Dati
|
||||
|
||||
Lo schema completo, con permessi e note, sta in [DATABASE.md](../DATABASE.md); qui il quadro
|
||||
funzionale.
|
||||
|
||||
**`notifiche_email_coda`** — una riga per notifica da mandare (`notifica_id` è chiave primaria e
|
||||
chiave esterna con `ON DELETE CASCADE`: cancellare la notifica, per esempio con lo swipe, toglie
|
||||
anche la mail non ancora partita).
|
||||
|
||||
| Colonna | Note |
|
||||
| -------------------- | ----------------------------------------------------------------------- |
|
||||
| `stato` | `in_coda` · `in_invio` · `inviata` · `fallita` · `saltata` |
|
||||
| `tentativi` | invii falliti finora; non cresce per i rinvii «differiti» (vedi sotto) |
|
||||
| `prossimo_tentativo` | il worker prende la riga solo se `<= now()` (backoff) |
|
||||
| `errore` | solo i codici SMTP dell'ultimo errore, mai il testo delle mail |
|
||||
| `inviata_il` | valorizzato a invio riuscito; alimenta il conteggio delle ultime 24 ore |
|
||||
| `aggiornata_il` | serve a riconoscere le righe rimaste `in_invio` per un worker morto |
|
||||
|
||||
È una tabella a parte e non colonne su `notifiche_utente` perché quella ha una policy `UPDATE` per
|
||||
il giocatore su tutta la riga: un client potrebbe riportare a `in_coda` una mail già partita. La
|
||||
coda ha la RLS attiva **senza alcuna policy** e i permessi tolti a `anon` e `authenticated`: la
|
||||
legge e la scrive solo la service role, cioè il worker.
|
||||
|
||||
**`preferenze_utente`** — una riga per **account** (`auth_user_id`, non lo slot giocatore), per ora
|
||||
con la sola colonna `email_notifiche` (default `true`). **Nessuna riga significa email attive**: la
|
||||
riga nasce solo quando il giocatore tocca l'interruttore, con un upsert; ognuno legge e scrive solo
|
||||
la propria, per RLS. Non sta su `giocatori_squadra`, che ha una riga per slot e il giocatore può
|
||||
modificare solo in parte (DD-016).
|
||||
|
||||
**Funzioni RPC** — tutte `SECURITY DEFINER` e con `EXECUTE` solo alla service role:
|
||||
|
||||
| Funzione | Cosa fa |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `prendi_notifiche_email(p_max)` | Prende fino a `p_max` righe `in_coda` scadute con `FOR UPDATE SKIP LOCKED`, le marca `in_invio` e restituisce indirizzo, oggetto, testo, evento, tipo. |
|
||||
| `esito_notifica_email(id, esito, errore, ora)` | Registra `inviata`, `riprova`, `differita` o `fallita`; agisce solo su righe `in_invio`, così un esito in ritardo non ne sovrascrive uno già deciso. |
|
||||
| `email_inviate_ultime_24h()` | Conta le mail inviate nelle ultime 24 ore, per restare sotto il tetto. |
|
||||
|
||||
## Flusso
|
||||
|
||||
1. Una sorgente M17 inserisce (o aggiorna con `upsert`) una riga in `notifiche_utente`.
|
||||
2. Il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`) la mette in
|
||||
`notifiche_email_coda`. L'`upsert` di turno palloni, sollecito presenze e sondaggio riscrive `creato_il`: è
|
||||
un rinvio voluto e rimette in coda anche la mail. Segnare come letta e l'`ON CONFLICT DO NOTHING`
|
||||
dei cron non toccano quella colonna e non accodano nulla.
|
||||
3. Ogni `POLL_SECONDI` (30) il worker conta le mail delle ultime 24 ore, ne chiede al massimo quante
|
||||
ne restano sotto il tetto (al più `MAIL_LOTTO`) a `prendi_notifiche_email` e le invia una a una.
|
||||
Prima di prendere il lotto la funzione SQL fa due pulizie: riporta a `in_coda` le righe ferme
|
||||
`in_invio` da più di 10 minuti (worker morto a metà) e segna `saltata` quelle senza destinatario
|
||||
o con le email spente.
|
||||
4. Ogni invio registra l'esito con `esito_notifica_email`.
|
||||
|
||||
La preferenza è valutata **al momento dell'invio**, non dell'accodamento: spegnere l'interruttore
|
||||
ferma anche le mail già in coda.
|
||||
|
||||
## Affidabilità
|
||||
|
||||
Il worker classifica l'errore SMTP (`classificaErroreSmtp`):
|
||||
|
||||
| Tipo | Esempi | Cosa succede |
|
||||
| ---------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `temporaneo` | 4xx, timeout, connessione caduta | Riprova con backoff 1 · 5 · 30 · 120 min; al quinto invio fallito passa a `fallita`. |
|
||||
| `permanente` | 5xx sul destinatario o sul messaggio, indirizzo non valido | `fallita` subito, senza altri tentativi. |
|
||||
| `limite` | `550 5.4.5 Daily user sending quota exceeded` | Tutto il lotto torna in coda tra un'ora **senza consumare tentativi**; il ciclo si ferma. |
|
||||
| `configurazione` | credenziali SMTP rifiutate (535, 534, 530, `EAUTH`) | Come sopra ma tra 15 minuti, e il container **non** risulta più sano (vedi sotto). |
|
||||
|
||||
Altre situazioni:
|
||||
|
||||
| Situazione | Comportamento |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Worker fermo o host spento | Le mail restano `in_coda` e partono alla ripartenza. |
|
||||
| Crash tra l'invio e la registrazione | Consegna _almeno una volta_: la riga resta `in_invio`, dopo 10 minuti torna in coda e la mail può arrivare due volte. Mai una perdita. |
|
||||
| Errore nella registrazione dell'esito | Non si tratta come errore SMTP (non parte un secondo invio): il ciclo si interrompe e la riga viene recuperata come sopra. |
|
||||
| Tetto giornaliero raggiunto | Finestra mobile di 24 ore su `inviata_il`, non «fino a mezzanotte»: il worker riprende da solo quando le mail più vecchie escono dalla finestra. |
|
||||
|
||||
`MAIL_LIMITE_GIORNO` vale 400 di default, sotto i circa 500 di Gmail, per lasciare margine ai
|
||||
messaggi che l'account dedicato potesse mandare per altre vie.
|
||||
|
||||
**Salute del container.** `mailer.mjs` scrive un timestamp in `/tmp/mailer-ok` a ogni ciclo
|
||||
riuscito; `healthcheck.mjs` (eseguito da Docker ogni minuto, con 30 secondi di tolleranza
|
||||
all'avvio) segnala non sano il container se l'ultimo è più vecchio di 5 minuti. Un ciclo fermato
|
||||
dalle credenziali SMTP rifiutate **non** aggiorna il file, così dopo qualche minuto `docker ps`
|
||||
mostra `unhealthy` invece di far credere che tutto funzioni. Un ciclo fermato dal solo tetto
|
||||
giornaliero è normale e resta sano.
|
||||
|
||||
## Contenuto delle mail
|
||||
|
||||
Sobrio, in testo semplice più una versione HTML minima leggibile da smartphone: oggetto = titolo
|
||||
della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un pulsante
|
||||
«Apri CrAPP» verso `<APP_URL>/evento/<id>` quando la notifica riguarda un evento e verso la home
|
||||
altrimenti. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni → Email).
|
||||
Nessuna immagine remota, nessun tracciamento delle aperture. Il testo scritto dall'admin viene
|
||||
sempre escapato nell'HTML. Il `Message-ID` è derivato dall'id della notifica.
|
||||
|
||||
## Interfaccia
|
||||
|
||||
Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore **«Email»** (icona busta)
|
||||
che scrive `preferenze_utente` per l'account, con lo stesso stile dell'interruttore «Notifiche»
|
||||
(`useEmailNotifiche()` in `src/lib/preferenze-utente.ts`). Vale per tutti gli slot dell'account.
|
||||
Nessuna schermata nuova.
|
||||
|
||||
## Privacy e sicurezza
|
||||
|
||||
- Le email personali dei giocatori non lasciano Supabase se non verso Gmail. Il worker legge solo
|
||||
l'indirizzo dei destinatari del lotto che sta spedendo, non l'intera rosa.
|
||||
- La service role sta solo sull'host del worker, in un `.env` con permessi `600`; non passa da
|
||||
Vercel.
|
||||
- La password per app si può revocare da Google senza toccare l'account.
|
||||
- I log del worker riportano solo id notifica, tipo ed esito: **mai indirizzi né testi**, e degli
|
||||
errori SMTP solo i codici. Il log del container è limitato a 3 file da 5 MB.
|
||||
- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore unico, come
|
||||
per la push (nessuna preferenza per tipo).
|
||||
|
||||
## Test
|
||||
|
||||
- **Unit** — [test/unit/mailer-core.test.ts](../../test/unit/mailer-core.test.ts): configurazione,
|
||||
classificazione degli errori SMTP, backoff, tetto giornaliero, composizione della mail (escape,
|
||||
oggetto, link, `Message-ID`), salute, e il ciclo di invio con dipendenze finte (esiti, fermata su
|
||||
tetto e credenziali, registrazione dell'esito che fallisce, log senza dati personali).
|
||||
[test/unit/preferenze-utente.test.ts](../../test/unit/preferenze-utente.test.ts): il default
|
||||
«acceso» senza riga.
|
||||
- **Integrazione** — [test/integration/notifiche-email.test.ts](../../test/integration/notifiche-email.test.ts),
|
||||
sul database locale: trigger su ogni tipo, rinvio da `upsert`, cron che non accoda due volte,
|
||||
cascata alla cancellazione, `prendi_notifiche_email` (destinatario, una sola volta, chiamate
|
||||
concorrenti senza doppioni, slot senza account o senza email, righe orfane recuperate),
|
||||
preferenza (default, spenta, riaccesa, RLS sulle righe altrui), tutti gli esiti, e i permessi (un
|
||||
giocatore o un admin non vedono la coda né chiamano le funzioni del worker).
|
||||
- Nessun test spedisce mail vere: il trasporto SMTP è sostituito da un finto nei test unitari.
|
||||
L'invio reale a Gmail è stato verificato a mano il 29/09/2026 (vedi Limiti noti).
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Il canale email dipende da un host acceso: non c'è alta disponibilità.
|
||||
- Un 250 da Gmail vuol dire «accettata», non «letta»: non c'è conferma di lettura e non se ne vuole
|
||||
una.
|
||||
- **Le prime mail possono finire in spam.** Verificato il 29/09/2026: la mail di prova, da un
|
||||
account nuovo e senza reputazione, è arrivata ma nella cartella spam. Non dipende dalla
|
||||
configurazione (Gmail accetta e consegna) ma dalla reputazione del mittente. Si migliora
|
||||
segnando i messaggi «Non è spam» e aggiungendo il mittente ai contatti; chi riceve la prima mail
|
||||
va avvisato. Se resta un problema serve un mittente con dominio proprio (vedi Evoluzioni).
|
||||
- Un account collegato a più slot giocatore (`auth_user_id` ripetuto) riceve una mail per slot, dato
|
||||
che `notifiche_utente` ha una riga per `giocatore_id`.
|
||||
- Cambiare l'indirizzo in `giocatori_squadra.email` cambia dove vanno le mail future; le righe già
|
||||
`inviata` non si toccano.
|
||||
- `notifiche_email_coda` non ha pulizia automatica, come `notifiche_utente` (DD-030): le righe
|
||||
spariscono quando la notifica viene eliminata.
|
||||
- L'interruttore «Email» compare solo con il codice che lo contiene: finché una versione più vecchia
|
||||
dell'app è in produzione, le mail partono ma non si possono spegnere dall'app.
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra, se le mail
|
||||
restano in spam o si supera il tetto di Gmail.
|
||||
- Riepilogo settimanale unico al posto di una mail per notifica.
|
||||
- Preferenze per tipo di notifica, nella stessa `preferenze_utente`, insieme a quelle della push.
|
||||
- Rendere il certificato in scadenza una notifica vera (sorgente nuova di `notifiche_utente`).
|
||||
@@ -0,0 +1,418 @@
|
||||
# Modulo — Notifiche
|
||||
|
||||
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push, più un
|
||||
centro notifiche in-app indipendente, con un pallino sull'avatar del profilo
|
||||
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/notifiche-utente.ts`,
|
||||
`src/lib/push-client.ts`, `src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
|
||||
`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`,
|
||||
`src/components/crapp/ui-bits.tsx` (`LinkProfilo`, `PallinoNotifiche`)
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
|
||||
indipendenti:
|
||||
|
||||
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
|
||||
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||||
|
||||
Gli avvisi fissi in Home (turno palloni, certificati in scadenza) non passano da nessuno dei
|
||||
due: sono card calcolate dai dati, che spariscono da sole quando la condizione finisce. In
|
||||
particolare il certificato in scadenza **non** genera push né voci nel centro notifiche
|
||||
(DD-035, specifica in [profilo-giocatore.md](profilo-giocatore.md#avviso-certificati)).
|
||||
|
||||
---
|
||||
|
||||
## Catalogo delle notifiche
|
||||
|
||||
Cosa fa partire una notifica, a chi arriva e su quali canali. Sono i canali indipendenti descritti
|
||||
più sotto: **push** (solo ai dispositivi che hanno attivato «Notifiche»), **centro notifiche
|
||||
in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email**
|
||||
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
|
||||
quelle del turno palloni**, che restano solo push e in-app: DD-040).
|
||||
|
||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
|
||||
| 1 | ~~Promemoria a 24 ore~~ — **rimosso** | Non parte più: i job `pg_cron` sono stati tolti dalla migration `m28` (DD-043). Restano le notifiche già generate (tipo `evento_promemoria_24h`). | — | — | — | — |
|
||||
| 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — |
|
||||
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
|
||||
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **una sola volta, 3 ore prima** dell'evento (DD-040, DD-042): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipo `turno_palloni_3h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m27`), push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) |
|
||||
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
|
||||
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||
|
||||
Le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
|
||||
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
||||
automatiche (4 e 5; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
||||
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
||||
lo stesso evento (vedi «Centro notifiche in-app»).
|
||||
|
||||
> **Righe 1, 2, 4 e 5 (DD-040).** Implementate con la migration `m26` (job `pg_cron`, testi, revoca) e
|
||||
> applicate in produzione (`m26` il 30/09/2026; `m27` il 01/10/2026, che porta il turno palloni a un solo
|
||||
> avviso 3 ore prima, DD-042).
|
||||
|
||||
Cosa riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche).
|
||||
|
||||
### Testi delle notifiche
|
||||
|
||||
Il testo è lo stesso su push, centro notifiche in-app ed email: titolo e corpo si scrivono una
|
||||
volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato di
|
||||
[notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
|
||||
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
|
||||
|
||||
| # | Notifica | Titolo | Corpo |
|
||||
| --- | --------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
|
||||
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
|
||||
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
|
||||
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
|
||||
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
|
||||
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
|
||||
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
|
||||
|
||||
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
|
||||
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
||||
`genera_solleciti_presenze_fascia`); i pulsanti usano gli stessi testi da `palloni-core.ts`
|
||||
(`avvisiPalloniEvento`) e `presenze.ts` (`testoSollecito`), e `notifiche-automatiche.test.ts` verifica che siano
|
||||
identici. In tutte la riga «Luogo» manca se l'evento non ha un luogo. Fino all'applicazione della `m26` in produzione valgono i testi di 1.2.0: promemoria
|
||||
«Promemoria: _titolo_» con corpo «_data_ alle _ora_», turno palloni «Tocca a te prendere i palloni» / «Porta
|
||||
i palloni», sollecito «Manca la tua risposta». Le righe 3 e 6 non cambiano:
|
||||
`notifica-personalizzata.ts` e `cacche.ts` (`avvisoSondaggio`).
|
||||
|
||||
**Notifiche smart (riga 7).** Solo locali: la notifica di sistema ha il titolo preceduto
|
||||
dall'emoji. Non entrano nel centro notifiche e non generano email.
|
||||
|
||||
| Situazione | Titolo | Corpo |
|
||||
| ---------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Badge sbloccato (🏅) | `<badge> <grado>` | `<valore> <unità>: badge sbloccato, complimenti!` |
|
||||
| Badge segreto | `Badge segreto: <nome>` | Testo del badge (`notificaPush`, poi `celebrazione`), altrimenti «Hai scoperto un badge nascosto!» |
|
||||
| A un passo da un badge (🔥) | `Sei a un passo` | `<N> <unità> e sblocchi <badge>.` — scatta con al massimo 2 unità mancanti |
|
||||
| Serie a un traguardo esatto (⚡) | `Serie di <N> <tipo>` | `Continuità da veterano: non fermarti ora.` |
|
||||
| Obiettivo di squadra tra 90% e 99% | `<obiettivo>: <pct>%` | Microtesto di `microcopyObiettivo()`, dipende dall'obiettivo |
|
||||
| Badge social vinto | `<categoria> x<N>` | `I tuoi compagni hanno votato per te.` |
|
||||
|
||||
### Cosa non genera nessuna notifica
|
||||
|
||||
- Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un
|
||||
promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze.
|
||||
- L'arrivo di un risultato, di una pagella o di un voto MVP.
|
||||
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi.
|
||||
- Assenze e infortuni.
|
||||
- La registrazione o il collegamento di un nuovo giocatore.
|
||||
- Il certificato in scadenza: resta una card in Home (DD-035).
|
||||
|
||||
### Comportamenti da conoscere
|
||||
|
||||
Sono l'effetto attuale della logica, non scelte documentate altrove. I punti sui **promemoria 1 e 2**
|
||||
descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessun job la chiama
|
||||
(DD-043); valgono solo se i job vengono ripianificati.
|
||||
|
||||
- **I promemoria (1 e 2) non distinguono il tipo di evento:** scattano per allenamenti, partite ed
|
||||
eventi extra-campo (per esempio una cena). Il turno palloni (4), invece, esiste solo per
|
||||
allenamenti e partite.
|
||||
- **Un evento creato a poche ore dall'inizio riceve subito i promemoria che gli spettano:** se
|
||||
mancano meno di 24 ore parte il promemoria «24 ore» al primo giro del job orario, e se ne
|
||||
mancano meno di 3 anche quello «3 ore», a distanza di pochi minuti.
|
||||
- **Ogni promemoria si genera una sola volta per giocatore, evento e tipo, anche se il giocatore
|
||||
lo elimina** (M23, DD-037): il job registra ciò che ha generato in `promemoria_eventi_generati`,
|
||||
una tabella indipendente da `notifiche_utente`. Eliminare la notifica dal centro notifiche non
|
||||
la fa tornare e non accoda una nuova mail. Prima di M23 tornava: la riga eliminata veniva
|
||||
rigenerata al giro successivo del job, con una nuova mail a ogni giro fino all'inizio
|
||||
dell'evento.
|
||||
- **Il registro non segue le modifiche all'evento:** se un evento viene spostato dopo che il
|
||||
promemoria è partito, non ne parte uno nuovo per la data nuova. Chi viene aggiunto ai convocati
|
||||
dopo riceve invece il suo promemoria, perché il registro è per giocatore.
|
||||
- **Le notifiche eliminate non tornano da sole:** la 3 e la 6 riappaiono solo se un admin
|
||||
rimanda il messaggio o riapre il sondaggio; quelle automatiche (1, 2, 4 e 5) non tornano affatto,
|
||||
perché il registro ricorda di averle già generate.
|
||||
- **Un evento già iniziato non genera più promemoria.**
|
||||
- **Due notifiche su sei dipendono solo da un gesto manuale** (3 e 6): se nessuno preme il pulsante,
|
||||
nessuno viene avvisato. Le altre quattro sono automatiche (DD-040); per turno palloni e sollecito il
|
||||
pulsante resta come aggiunta e può arrivare in più rispetto a quelle automatiche.
|
||||
- **Il turno palloni parte solo se l'incaricato è confermato** (DD-040): per una partita la
|
||||
proposta automatica di `completaTurni()` non salvata non basta. Se l'incaricato cambia dopo
|
||||
un avviso, il nuovo riceve il proprio al giro successivo e il precedente riceve una sola volta
|
||||
«Turno palloni: incarico revocato»; se viene poi riassegnato, non riceve un secondo avviso.
|
||||
- **Il sollecito automatico è uno solo, a 24 ore** (DD-045): parte per gli eventi che iniziano entro 24
|
||||
ore, a chi in quel momento non ha risposto o ha risposto «forse». Un evento creato a meno di 24
|
||||
ore dall'inizio lo riceve comunque, una volta. Chi risponde dopo non ne riceve altri; chi resta su
|
||||
«forse» non lo riceve una seconda volta. Altri solleciti solo col pulsante manuale.
|
||||
- **Per ogni evento un giocatore riceve al massimo una mail automatica** (DD-045): il
|
||||
sollecito presenze (il promemoria è stato tolto, DD-043), più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
|
||||
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
|
||||
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
|
||||
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
|
||||
|
||||
### Test che verificano il catalogo
|
||||
|
||||
| Cosa | Test |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
|
||||
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
||||
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
||||
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
|
||||
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||
|
||||
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
|
||||
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
|
||||
catena `useIo()` → `useRosa()` → `useNotificheSmart()` è fatta di hook e si conosce dalla lettura del
|
||||
codice; l'unico pezzo puro, `inRosa`, è coperto.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`, con le chiavi `p256dh` e
|
||||
`auth` con cui si cifra il payload per quel dispositivo). La tabella `promemoria_push` non è
|
||||
più usata da nessuno: serviva da coda del testo quando la push partiva vuota (DD-026).
|
||||
|
||||
---
|
||||
|
||||
## Iscrizione alle notifiche push
|
||||
|
||||
In Profilo → Opzioni c’è **un solo interruttore** («Notifiche»). Non esistono preferenze
|
||||
separate per tipo di messaggio: l’iscrizione registra il dispositivo e lo rende destinatario
|
||||
di **tutte** le push (promemoria palloni, solleciti presenze) e abilita anche le notifiche
|
||||
smart in app, che usano lo stesso service worker.
|
||||
|
||||
1. Il giocatore attiva «Notifiche» in `/profilo` → richiesta permesso browser.
|
||||
2. `GET /api/public/push-config` restituisce solo la chiave pubblica VAPID.
|
||||
3. Registrazione del service worker `public/push-sw.js` e `pushManager.subscribe()`.
|
||||
4. `POST /api/public/push-subscribe` registra endpoint e chiavi in `push_subscriptions`
|
||||
(upsert).
|
||||
|
||||
All'avvio e quando l'app torna visibile viene richiesto l'aggiornamento della registrazione
|
||||
push esistente con `ServiceWorkerRegistration.update()`. Non si chiede un nuovo permesso,
|
||||
non si ricrea la sottoscrizione e non si cambia l'endpoint: anche chi ha già attivato le
|
||||
notifiche deve ricevere le correzioni del worker senza spegnere e riaccendere l'interruttore.
|
||||
Gli aggiornamenti contemporanei sono accorpati; un errore di rete non blocca l'app e si
|
||||
riprova al ritorno in primo piano. Il worker attende `skipWaiting()` durante l'installazione.
|
||||
Il browser controlla anche autonomamente gli aggiornamenti: questa richiesta esplicita
|
||||
copre in particolare le sessioni lunghe della webapp (vedi il
|
||||
[ciclo di vita del service worker](https://web.dev/articles/service-worker-lifecycle)).
|
||||
|
||||
---
|
||||
|
||||
## Ruolo delle tre route pubbliche
|
||||
|
||||
- **`push-config`** — espone la sola chiave pubblica VAPID.
|
||||
- **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo.
|
||||
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a tutti i dispositivi
|
||||
iscritti (allenatori esclusi) l'avviso di apertura del sondaggio pre-partita e lo scrive anche in
|
||||
`notifiche_utente` per i giocatori attivi, così compare nel centro notifiche e parte per email
|
||||
(DD-038; vedi [Scout Live](scout-live.md)).
|
||||
|
||||
L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un JWT VAPID
|
||||
(ECDSA P-256), cifra `{title, body}` per il dispositivo destinatario e fa una POST
|
||||
all'endpoint push del browser; è riusato identico da `sollecita-presenze.ts`,
|
||||
`promemoria-palloni.ts` e `apri-sondaggio.ts` .
|
||||
|
||||
Il testo viaggia **dentro** la push, cifrato in `aes128gcm` (RFC 8188/8291) con le chiavi del
|
||||
dispositivo: il service worker fa `event.data.json()` e mostra la notifica senza toccare la
|
||||
rete. È il punto decisivo per la consegna ad app chiusa — il browser sveglia il worker per
|
||||
pochi secondi, e una fetch per recuperare il testo lo faceva morire prima di
|
||||
`showNotification` (DD-026).
|
||||
|
||||
La POST porta `Urgency: high`. Con l'urgenza predefinita ("normal") un telefono in risparmio
|
||||
energetico accumula i messaggi fino al risveglio: la notifica arriva solo quando il
|
||||
dispositivo è già attivo — cioè, nella pratica, solo con l'app aperta.
|
||||
|
||||
### Chi può farle partire (DD-024, DD-025)
|
||||
|
||||
Queste route usano la service role e saltano la RLS, quindi il permesso deve stare nella
|
||||
route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi il controllo
|
||||
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
|
||||
variabile d'ambiente.
|
||||
|
||||
| Route | Controllo | Chi la chiama |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
|
||||
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
|
||||
|
||||
Con DD-040 `promemoria-palloni` e `sollecita-presenze` restano, accanto ai job automatici (la seconda
|
||||
anche per l'allenatore, `richiediGestoreEventi()`).
|
||||
|
||||
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
|
||||
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
|
||||
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
|
||||
La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate:
|
||||
l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non
|
||||
l'ha ancora attivata.
|
||||
|
||||
`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId`
|
||||
lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a
|
||||
quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto
|
||||
dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione
|
||||
che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard
|
||||
admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore.
|
||||
|
||||
---
|
||||
|
||||
## Notifiche smart
|
||||
|
||||
`calcolaNotifiche()` (`notifiche-smart.ts`) genera un evento solo quando "c'è qualcosa di
|
||||
reale": badge appena sbloccato, "sei a un passo" da un traguardo, serie che raggiunge un
|
||||
traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto. Ogni notifica
|
||||
ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non
|
||||
ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
|
||||
|
||||
**Chi le riceve.** Solo chi ha selezionato sul dispositivo uno slot **giocatore in rosa**:
|
||||
`CelebrazioneBadge` (montato una volta in `__root.tsx`) passa a `useNotificheSmart()` il giocatore
|
||||
letto da `useGiocatoreCorrente()`, cioè `useIo()`, che lo cerca in `useRosa()`. Quell'elenco esclude
|
||||
gli allenatori (`inRosa`, DD-034), che non hanno statistiche: per loro `useIo()` è `null` e
|
||||
`useNotificheSmart()` esce subito, senza calcolare nulla né mostrare la card celebrativa o la
|
||||
notifica del sistema. Vedi [allenatore.md](allenatore.md#notifiche).
|
||||
|
||||
---
|
||||
|
||||
## Centro notifiche in-app (M17)
|
||||
|
||||
Pallino sull'angolo dell'avatar del profilo in alto a destra (`PallinoNotifiche` dentro
|
||||
`LinkProfilo`, `src/components/crapp/ui-bits.tsx`): rosso con il numero delle non lette; se
|
||||
sono tutte lette resta neutro con il totale, così le lette restano raggiungibili per
|
||||
eliminarle; senza notifiche non compare. Il tap sull'avatar porta sempre a `/profilo`, solo il
|
||||
tap sul pallino apre il pannello (logica in `pallinoNotifiche()`). Fino a 0.9.2 era una
|
||||
campanella separata accanto all'avatar. Indipendente dal canale push sopra:
|
||||
non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico persistente in
|
||||
`notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la
|
||||
stessa cosa delle notifiche smart (quelle restano locali, non salvate a database).
|
||||
|
||||
Cinque sorgenti scrivono in `notifiche_utente`, mai il client (con DD-040 le sorgenti 3 e 4 hanno in più un
|
||||
job `pg_cron` come la 2, che scrive `turno_palloni_3h`/`_revocato` (`_12h`/`_6h` solo fino a `m26`) e `sollecita_presenze_24h` (`_12h`/`_6h` solo fino a `m29`); i pulsanti restano e scrivono `turno_palloni` e `sollecita_presenze`):
|
||||
|
||||
1. **Messaggio admin** — `notifica-personalizzata.ts` inserisce una riga per destinatario
|
||||
in parallelo all'invio push esistente (a tutta la rosa attiva se `giocatoreId` è omesso).
|
||||
2. **Promemoria evento (rimosso dalla `m28`, DD-043: i job non esistono più)** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15
|
||||
minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati
|
||||
(o tutta la rosa attiva se l'evento non ne specifica), deduplicata dal registro
|
||||
`promemoria_eventi_generati` (M23, DD-037; il vincolo `UNIQUE (giocatore_id, evento_id, tipo)`
|
||||
di `notifiche_utente` resta come seconda difesa) così un cron che gira più volte non manda
|
||||
doppioni, nemmeno se il giocatore elimina la notifica. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta
|
||||
l'avvicinarsi della data, non il momento in cui è stato messo in calendario.
|
||||
3. **Turno palloni** — `promemoria-palloni.ts` (bottone riservato agli admin sulla pagina
|
||||
evento, vedi [Turno palloni](palloni.md)) inserisce una riga per ciascun avviso calcolato
|
||||
da `avvisiPalloniEvento()`, in parallelo alla push.
|
||||
4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una
|
||||
riga per ciascun giocatore che non ha ancora risposto, in parallelo alla push.
|
||||
5. **Sondaggio pre-partita** — `apri-sondaggio.ts` (pulsante riservato agli admin sulla pagina
|
||||
della partita) inserisce una riga di tipo `sondaggio_cacche` per ciascun giocatore attivo,
|
||||
allenatori esclusi (`destinatariSondaggio()`), in parallelo alla push (M24, DD-038).
|
||||
|
||||
Turno palloni, sollecito presenze e sondaggio usano un `upsert` su `(giocatore_id, evento_id, tipo)`
|
||||
invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stesso evento, la
|
||||
notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false)
|
||||
invece di duplicarsi o fallire per il vincolo `UNIQUE`.
|
||||
|
||||
Il corpo delle notifiche automatiche è a righe etichettate (Data, Ora, Luogo…): `RigaNotifica` lo mostra con
|
||||
`whitespace-pre-line`, e un test di `unit/notifiche-utente.test.ts` verifica che la classe ci sia.
|
||||
|
||||
Lettura, "segna come letta" ed eliminazione passano dal client Supabase autenticato con RLS
|
||||
(`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()`), senza una route API dedicata
|
||||
— stesso pattern di `useSalvaEvento()` in `src/lib/eventi.ts`. Aprire il pannello segna tutte
|
||||
le notifiche del giocatore selezionato come lette; non c'è un pulsante "segna singola"
|
||||
separato.
|
||||
|
||||
**Non c'è pulizia automatica delle notifiche vecchie** (DD-030): l'unico modo per farle
|
||||
sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla riga
|
||||
(`RigaNotifica` in `ui-bits.tsx`, stessa fisica a molla dello swipe del calendario) o con la
|
||||
× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la
|
||||
ritrova per sempre nell'elenco, solo segnata come letta.
|
||||
|
||||
## Push dei promemoria (M24)
|
||||
|
||||
I promemoria a 24 e 3 ore (rimossi con la `m28`, DD-043; la coda resta valida per i tipi già esistenti) nascevano nel database (job `pg_cron`), che non può firmare né cifrare una
|
||||
push Web: la manda il worker `mailer/`, lo stesso che invia le email ([WORKER_EMAIL.md](../WORKER_EMAIL.md),
|
||||
DD-038).
|
||||
|
||||
1. Quando il job crea un promemoria, un trigger lo accoda in `notifiche_push_coda` (solo i tipi
|
||||
`evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano già la push
|
||||
dall'app, e una seconda sarebbe un doppione).
|
||||
2. Il worker, a ogni giro, chiama `prendi_push_promemoria()`, che restituisce testo e dispositivi
|
||||
iscritti del giocatore, e manda la push a **tutti** i suoi dispositivi con lo stesso modulo
|
||||
dell'app (`src/lib/webpush.server.ts`), quindi con le stesse regole di cifratura, urgenza e TTL.
|
||||
3. Un solo esito per notifica: `inviata` se almeno un dispositivo accetta; `riprova` con backoff
|
||||
1 · 5 · 30 · 120 minuti sugli errori temporanei (429, 5xx, rete), poi `fallita`; `fallita`
|
||||
subito su un rifiuto permanente (400, 401, 403, 413: tipicamente chiavi VAPID sbagliate);
|
||||
`saltata` se le iscrizioni erano tutte scadute o il giocatore non ne ha.
|
||||
4. Le iscrizioni che rispondono 404 o 410 vengono eliminate da `push_subscriptions`, come fanno le
|
||||
route.
|
||||
5. Una push in coda da oltre 3 ore dalla creazione del promemoria si scarta (`saltata`): a evento
|
||||
ormai iniziato sarebbe solo rumore. Vale se il worker resta fermo a lungo.
|
||||
|
||||
La push dei promemoria dipende solo dai dispositivi iscritti, non dall'interruttore «Email». Se il
|
||||
worker non ha le chiavi VAPID parte lo stesso e manda solo le email, segnalandolo nel log.
|
||||
|
||||
Con DD-040 il trigger accoda in `notifiche_push_coda` anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
||||
`sollecita_presenze_24h`, `_12h` e `_6h`, perché nascono nel database e nessuna route manda la
|
||||
push (quelle dei pulsanti, `turno_palloni` e `sollecita_presenze`, restano fuori dalla coda): le regole di invio, backoff e scarto sono quelle qui sopra. Un sollecito in coda da oltre 3 ore si scarta come un
|
||||
promemoria.
|
||||
|
||||
## Canale email (M22)
|
||||
|
||||
Ogni riga di `notifiche_utente` viene inviata anche per email da un worker esterno, con
|
||||
un interruttore «Email» per account in Profilo → Opzioni: [notifiche-email.md](notifiche-email.md).
|
||||
Non cambia nulla di quanto descritto sopra: push, notifiche smart e centro notifiche restano
|
||||
indipendenti e le sorgenti scrivono `notifiche_utente` come prima. Il certificato in
|
||||
scadenza resta una card in Home e non genera mail (DD-035).
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
|
||||
è iscritto o no. Separare i canali richiederebbe schema e UI dedicati.
|
||||
- La tabella `promemoria_push` è rimasta nel database ma non la usa più nessuno (DD-026): va
|
||||
eliminata con una migrazione alla prossima occasione.
|
||||
- **Un 2xx dal server push non significa consegnato.** FCM accetta con 201 anche verso
|
||||
registrazioni scadute e poi butta via il messaggio, senza il 404/410 che farebbe pulire
|
||||
`push_subscriptions`. Il conteggio "inviate a N dispositivi" va letto come "accettate da N
|
||||
server push", non come "arrivate a N telefoni".
|
||||
- Compatibilità iOS/Safari non gestita esplicitamente nel codice (nessun branch dedicato):
|
||||
serve l'installazione da schermata Home per funzionare, ma l'app non lo segnala
|
||||
esplicitamente. È il primo sospetto quando una notifica non arriva ad app chiusa su iPhone.
|
||||
- **Su Android non riceve la webapp: riceve il browser.** Il WebAPK è solo l'identità con
|
||||
cui la notifica viene mostrata; la connessione con i server push la tiene Chrome, tramite
|
||||
Google Play Services. Se Android non può avviare Chrome, il messaggio resta in coda e
|
||||
compare tutto insieme al lancio successivo — il sintomo classico è «arriva solo quando
|
||||
riapro l'app». Un 201 dal servizio push non lo distingue in alcun modo da una consegna
|
||||
riuscita.
|
||||
|
||||
Verificato sul campo (settembre 2026, Motorola): con Chrome vivo in secondo piano la push
|
||||
arriva ad app chiusa e schermo bloccato, WebAPK compreso — quindi server, cifratura,
|
||||
service worker, permesso notifiche e canale erano già corretti. L'unica condizione che
|
||||
fallisce è **Chrome non in esecuzione**. La cura sta in Impostazioni → App → **Chrome** →
|
||||
Batteria → «Senza restrizioni», più Impostazioni → Batteria → «Batteria adattiva»
|
||||
disattivata. Mettere «Senza restrizioni» solo su CrAPP non basta e depista.
|
||||
|
||||
**Come misurarlo invece di indovinare:** `chrome://gcm-internals` sul telefono, sezione
|
||||
«Receive Message Log». Se la riga porta l'orario dell'invio, il messaggio era arrivato e
|
||||
non è stato mostrato (permesso o canale); se porta l'orario in cui si è riaperta l'app,
|
||||
non era stato consegnato (risveglio, quindi batteria). Attenzione: tenere quella scheda
|
||||
aperta **tiene Chrome vivo**, quindi falsa la prova stretta — per quella, nessuna scheda
|
||||
aperta e Chrome tolto dai recenti.
|
||||
|
||||
- Su Motorola verificare anche le restrizioni del **browser che ha installato CrAPP** e,
|
||||
dove presente, Impostazioni → Batteria → Ottimizzazione standby app. Il produttore
|
||||
documenta la limitazione dei processi in background
|
||||
([guida Motorola](https://help.motorola.com/hc/3505/14/global/en-us/CG2007980805.html)).
|
||||
È una possibile causa del sintomo, non una diagnosi verificata sul dispositivo: il
|
||||
codice web non può rimuovere questi vincoli. La verifica richiede un invio da un altro
|
||||
dispositivo mentre CrAPP è chiusa e lo schermo del Motorola è bloccato. Il pulsante di
|
||||
prova invia subito, quindi da solo non dimostra la ricezione in background.
|
||||
- Le notifiche smart dipendono da un service worker già registrato: se il giocatore non ha
|
||||
mai attivato le push, `notificaSistema()` non ha un `reg` a cui appoggiarsi e la notifica
|
||||
locale non viene mai mostrata, anche con permesso concesso.
|
||||
- Il payload cifrato non può superare i ~4 KB: i testi attuali stanno larghi, ma un messaggio
|
||||
molto lungo verrebbe rifiutato dal servizio push.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Preferenze per canale (palloni, solleciti, smart), se servono davvero alla squadra.
|
||||
- Eliminare `promemoria_push` con una migrazione.
|
||||
- Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).
|
||||
@@ -0,0 +1,174 @@
|
||||
# Modulo — Obiettivi di squadra
|
||||
|
||||
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
|
||||
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
|
||||
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Mostrare traguardi collettivi (non individuali) che avanzano con il contributo di tutta la
|
||||
rosa — presenze, risposte alle convocazioni, pagelle, risultati di campionato — per motivare
|
||||
comportamenti di squadra oltre alla singola prestazione.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts`
|
||||
(`obiettiviSquadra()`) che legge dati già aggregati altrove (`risposte_presenze`,
|
||||
`pagelle_voti`, i risultati ufficiali CSI, le serie di presenza). `obiettiviOrdinati()` li
|
||||
ordina mettendo i completati in coda e gli altri per progresso decrescente.
|
||||
|
||||
Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi
|
||||
sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento
|
||||
sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia
|
||||
esplicita. Il problema che _sembrava_ riguardare gli obiettivi era in realtà nelle tabelle
|
||||
collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la
|
||||
cancellazione: risolto a livello database con un trigger (migration
|
||||
`m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi.
|
||||
|
||||
`obiettiviSquadra(rosa, ctx, oggi)` accetta un terzo parametro opzionale `oggi: Date` (default
|
||||
`new Date()`) per iniettare una data deterministica nei test — usato dai due obiettivi con mese
|
||||
corrente dinamico (vedi sotto).
|
||||
|
||||
---
|
||||
|
||||
## Obiettivi definiti
|
||||
|
||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- |
|
||||
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
|
||||
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
||||
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
||||
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
||||
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
||||
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
|
||||
|
||||
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
||||
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
||||
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
|
||||
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
|
||||
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
|
||||
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
|
||||
|
||||
---
|
||||
|
||||
## Obiettivi mensili — mese dinamico
|
||||
|
||||
`o1` ("90% presenze del mese") e `o6` ("1 evento di squadra al mese") si azzerano
|
||||
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
|
||||
(fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo
|
||||
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
|
||||
seguono di conseguenza.
|
||||
|
||||
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
|
||||
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
|
||||
anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa.
|
||||
|
||||
---
|
||||
|
||||
## Continuità di squadra — il target 12 è il minimo per un 6vs6
|
||||
|
||||
Il target di 12 giocatori con almeno 3 allenamenti consecutivi (`o11`) **non è arbitrario**: è
|
||||
il numero minimo di giocatori per schierare due sestetti (6 contro 6) in allenamento. A
|
||||
differenza degli altri target fissi, non va scalato in proporzione alla rosa se questa cambia
|
||||
dimensione — resta 12 finché l'obiettivo è "riuscire ad allenarsi in modo completo".
|
||||
|
||||
Dipende da `serieAllenamenti` (vedi [Serie di presenze](serie-presenze.md)), calcolato sui dati
|
||||
reali: un evento passato senza risposta vale come assenza e azzera la serie, quindi l'obiettivo
|
||||
misura anche quanto la squadra risponde alle convocazioni, non solo la presenza fisica.
|
||||
|
||||
---
|
||||
|
||||
## Vittorie in campionato (o3/o4/o5) — dipendenza dal portale CSI
|
||||
|
||||
Le vittorie (`ctx.vittorie`) arrivano dal **JSON** delle partite del portale CSI Bologna
|
||||
(`getEventsByTeamId.php`, non la pagina HTML della classifica), tramite
|
||||
`partiteGiocate(csi.partite).filter(p => p.setNostri > p.setLoro)` calcolato in
|
||||
`src/lib/rosa.ts` (`useObiettivi()`). `o3`/`o4`/`o5` sono lo stesso numero di vittorie letto a
|
||||
tre soglie diverse (1/5/10), ciascuna cappata con `Math.min` — nessuna delle tre supera mai il
|
||||
proprio target, nemmeno con più vittorie di quante ne servano.
|
||||
|
||||
### Il limite: il parsing del JSON può rompersi in silenzio
|
||||
|
||||
`result` e `partials` nella risposta di `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" — **senza
|
||||
errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()`
|
||||
torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie
|
||||
reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un
|
||||
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme, quindi se si rompe solo
|
||||
il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde
|
||||
comunque `200` con `partite: []`.
|
||||
|
||||
### Come è mitigato oggi
|
||||
|
||||
- **`partiteFormatoSospetto()`** (`csi-core.ts`) confronta gli eventi grezzi ricevuti con il
|
||||
risultato di `partiteDaEventi()`: se ci sono eventi ma nessuno è stato riconosciuto come
|
||||
nostra partita, il formato è quasi certamente cambiato (distingue così un vero "formato
|
||||
rotto" da un legittimo "nessuna gara ancora in programma", dove gli eventi grezzi sono vuoti
|
||||
anche loro).
|
||||
- La route (`src/routes/api/public/csi.ts`) logga un `console.error` quando succede.
|
||||
- Il flag viaggia anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
|
||||
(`src/routes/classifica.tsx`), dove sostituisce la riga "Dati CSI aggiornati alle..." con un
|
||||
badge discreto color warning ("Il portale CSI potrebbe aver cambiato formato: dati da
|
||||
verificare.") — visibile a chi apre la pagina campionato, non solo nei log del server.
|
||||
|
||||
### Come fixarlo, se succede
|
||||
|
||||
1. **Vedere il nuovo formato**: guardare la risposta reale dell'endpoint, o lanciare
|
||||
`CSI_LIVE=1 bun test/unit/csi-core.test.ts` (interroga il portale vero).
|
||||
2. **Aggiornare il parsing** in `src/lib/csi-core.ts`: quasi sempre basta toccare
|
||||
`punteggio()`/`parziali()` (le regex sul formato del punteggio) o i nomi dei campi letti in
|
||||
`partiteDaEventi()`. Il resto dell'app consuma solo i tipi già puliti che questo file
|
||||
produce (`DatiCsi`, `PartitaCsi[]`), quindi il fix resta isolato.
|
||||
3. Serve toccare anche `src/routes/api/public/csi.ts` solo se cambiano gli **URL/endpoint**
|
||||
stessi o serve autenticazione — non per un semplice cambio di formato dei dati.
|
||||
4. **Aggiornare i test**: `test/unit/csi-core.test.ts` con fixture nel nuovo formato, altrimenti
|
||||
restano verdi contro un formato che non esiste più.
|
||||
|
||||
Dettagli completi (endpoint, identificativi di stagione, altri limiti del collegamento CSI) in
|
||||
[Collegamento CSI](collegamento-csi.md).
|
||||
|
||||
---
|
||||
|
||||
## Copertura test
|
||||
|
||||
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
|
||||
da un backend reale, non solo funzione pura con contesto costruito a mano).
|
||||
|
||||
| Obiettivi | Unit test | Integration test |
|
||||
| ---------- | ----------------------------- | -------------------------------------------------------- |
|
||||
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
|
||||
|
||||
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
|
||||
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
|
||||
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
|
||||
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
|
||||
(evento dentro/fuori mese), scadenza dinamica.
|
||||
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
|
||||
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
|
||||
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
|
||||
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
|
||||
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
|
||||
della tabella (`pagelle_no_autovoto`, `pagelle_voto_range`), li rilegge, verifica media
|
||||
arrotondata e conteggio.
|
||||
- **o3/o4/o5** (CSI reale, non Supabase — le vittorie non toccano il database): estende
|
||||
`test/integration/api.test.ts`, che già chiama `/api/public/csi` dal vivo. Legge le vittorie
|
||||
vere del giorno con la stessa logica di `useObiettivi()`, le passa a `obiettiviSquadra()` e
|
||||
verifica cap e target su dati reali.
|
||||
|
||||
Per rilanciare tutto: `npm run test` (unit, nessuna rete) e `npm run test:integration`
|
||||
(richiede `npx supabase start` per o1/o2/o6/o7/o11/o12/o13, e rete verso CSI Bologna per
|
||||
o3/o4/o5 — quest'ultimo gira comunque anche senza stack Supabase locale).
|
||||
@@ -0,0 +1,76 @@
|
||||
# Modulo — Pagelle
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/pagelle.ts`, `src/components/crapp/Pagelle.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Voto tra compagni (1-10) a fine partita per ciascun convocato, usato per calcolare una media
|
||||
personale mostrata nel profilo e una media di squadra.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Tabella `pagelle_voti`, con vincoli imposti a livello database: `CHECK voto BETWEEN 1 AND 10`,
|
||||
`CHECK votante_id <> votato_id` (anti auto-voto imposto anche dal database, non solo dalla
|
||||
UI), `UNIQUE (match_id, votante_id, votato_id)`.
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
- Il pannello `Pagelle` compare in `partita.$id.tsx` solo se esiste un risultato per la
|
||||
partita (scout salvato o dato CSI).
|
||||
- Ogni convocato può votare tutti gli altri convocati, mai se stesso — escluso sia in UI sia
|
||||
dal vincolo DB.
|
||||
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
|
||||
volte, l'ultimo voto sovrascrive il precedente.
|
||||
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
|
||||
**tutti i voti mai ricevuti** — l'app non ha un concetto di stagione/reset, quindi non è
|
||||
"la media di questa stagione" ma lo storico completo; `pagellePartita()` la calcola per
|
||||
singola partita; `mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in
|
||||
`squadra.tsx`.
|
||||
- `useRosa()` inietta questa media storica nel campo `mediaVoto` di ogni giocatore, insieme al
|
||||
numero di voti ricevuti (`votiPagella`) — usato dal badge Pagellone (vedi
|
||||
[badge.md](badge.md)) per richiedere un minimo di voti prima che la media conti, e mostrato
|
||||
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
|
||||
- La StatTile **home** applica la stessa soglia del badge Pagellone (DD-028) tramite la
|
||||
funzione pura `mediaVotoColpoDOcchio()`: sotto `VOTI_MINIMI_PAGELLA` voti ricevuti mostra
|
||||
`—` invece della media, non solo quando i voti sono zero. È stata estratta come funzione
|
||||
testabile (coerente con DD-020) invece di restare una condizione inline nella route. Le
|
||||
StatTile di **profilo** e **squadra** non applicano questa soglia (vedi "Limiti noti").
|
||||
|
||||
---
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI).
|
||||
- L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di
|
||||
voto in UI **e**, da M13, rifiuta anche a database un voto scritto dopo la chiusura (RLS
|
||||
`evento_permette_voto()`, `pagelle_voti`).
|
||||
- Da M13 anche il votante e il votato devono essere convocati all'evento: verificato a
|
||||
database, non solo in UI (stessa RLS di sopra).
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id`
|
||||
sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non
|
||||
mostra mai il votante, ma il dato non è né aggregato né mascherato lato server.
|
||||
- La media mostrata nel **profilo** e in **squadra** non richiede un numero minimo di voti:
|
||||
con un solo voto ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`)
|
||||
e la StatTile **home** (DD-028) applicano invece la stessa soglia minima prima di
|
||||
considerarla — profilo e squadra no.
|
||||
- Le due regole di M13 (convocazione, `pagelle_chiuse`) valgono solo per la policy "Ognuno
|
||||
gestisce i propri voti pagella": un amministratore può ancora correggere un voto fuori
|
||||
convocazione o dopo la chiusura, di proposito (deve poter sistemare un errore).
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Modulo — Palloni
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/palloni.ts`, `src/lib/palloni-core.ts`,
|
||||
`src/components/crapp/TurnoPalloni.tsx`, `src/components/crapp/PromemoriaPalloni.tsx`,
|
||||
`src/routes/api/public/promemoria-palloni.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
|
||||
partite, con proposta automatica, possibilità di modifica manuale e avvisi push, nel centro
|
||||
notifiche e senza email 3 ore prima dell'evento (DD-040, DD-042). Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
|
||||
compleanni non c'entrano: non richiedono palloni.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Tabella `turni_palloni` (`evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`) —
|
||||
contiene solo i turni **confermati manualmente**; le proposte automatiche non salvate non vi
|
||||
compaiono.
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
- `eventiPalloni()` (`palloni-core.ts`) ordina gli eventi per data, poi ora, poi id (lo stesso ordine dei job SQL di `m26`: «evento precedente» e «successivo» dipendono da qui) ed è il filtro comune a tutta la pipeline (proposte,
|
||||
conteggi, serie, promemoria): include solo `tipo === "partita"` e `tipo === "allenamento"`,
|
||||
scarta eventi extra-campo e compleanni.
|
||||
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** senza turno già salvato,
|
||||
il candidato con meno turni fatti, poi quello che non lo fa da più tempo, poi per ordine
|
||||
alfabetico — un algoritmo greedy, non un ordine fisso né solo per data. Gli **allenamenti**
|
||||
non ricevono proposta automatica: restano «da assegnare» finché qualcuno non sceglie un
|
||||
incaricato in `TurnoPalloni` (scelta della squadra).
|
||||
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
|
||||
upsert su `evento_id`.
|
||||
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
|
||||
ricalcolato a runtime da `conteggioTurni()` sui **soli turni confermati** (`turniSalvati`
|
||||
in `rosa.ts`) — non è uno storico in tabella dedicata, ma non include le proposte
|
||||
automatiche di `completaTurni()` (quelle restano solo per la UI di rotazione,
|
||||
`TurnoPalloni.tsx`/`PromemoriaPalloni.tsx`). Conta solo gli eventi già passati (`e.data <
|
||||
oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un allenamento
|
||||
futuro non è ancora "portato", quindi non sale finché quel giorno non arriva.
|
||||
- `serieConsecutivaPalloni()` (`palloni-core.ts`) calcola le volte **consecutive** in cui il
|
||||
giocatore ha portato i palloni (`Giocatore.seriePalloni` in `rosa.ts`), mostrate nel
|
||||
sottotitolo della classifica interna di Squadra quando si ordina per Palloni. Stesso
|
||||
criterio "solo eventi già passati" di `conteggioTurni()`; un evento passato senza turno
|
||||
confermato non spezza la serie di nessuno (viene saltato, non conta come "non portati").
|
||||
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
|
||||
è il banner in Home per il giocatore di turno.
|
||||
|
||||
---
|
||||
|
||||
## Route API pubblica `/api/public/promemoria-palloni`
|
||||
|
||||
> **Affiancata da un invio automatico (DD-040, migration `m26`).** Oltre al pulsante, un job
|
||||
> `pg_cron` ogni 15 minuti manda l'avviso **una sola volta, 3 ore prima** dell'evento (DD-042, migration `m27`), solo se per l'evento c'è un
|
||||
> incaricato confermato in `turni_palloni`. Il pulsante e la route **restano**, con tipo
|
||||
> `turno_palloni` e push dalla route. I due destinatari e i testi nuovi («incarico assegnato» /
|
||||
> «riconsegna», validi per il pulsante e per i job, più la revoca se l'incaricato cambia) sono nel
|
||||
> [catalogo](notifiche.md#testi-delle-notifiche). Il resto della sezione descrive il pulsante.
|
||||
|
||||
La fa partire un **amministratore** dal pulsante «Avvisa chi è di turno» dentro il riquadro
|
||||
palloni dell'evento (`TurnoPalloni.tsx`), riservato agli admin (DD-025). Riceve l'`eventoId`,
|
||||
e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve **prendere** i
|
||||
palloni e chi deve **riportarli** (l'incaricato dell'evento precedente), con un testo diverso
|
||||
per ciascuno.
|
||||
|
||||
Titolo e testo viaggiano cifrati dentro la push, quindi il service worker li mostra senza
|
||||
nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
|
||||
[Notifiche](notifiche.md)).
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **L'avviso automatico parte solo con un incaricato confermato** (DD-040): per una partita la
|
||||
proposta di `completaTurni()` non salvata non basta, e senza incaricato non parte nulla. Il pulsante
|
||||
dell'admin (DD-025) resta per rimandare l'avviso o per una proposta non ancora confermata. `destinatariPromemoriaPalloni()` — la versione "chi è di
|
||||
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
|
||||
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
|
||||
"assente" o "infortunato" per quell'evento.
|
||||
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
|
||||
include solo partite e allenamenti): guarda solo `e.data < oggi`. Un turno registrato per
|
||||
errore su un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge
|
||||
Sherpa dei palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa
|
||||
combinazione, dato che né `TurnoPalloni` né i promemoria automatici propongono più un turno
|
||||
per eventi extra-campo o compleanni — ma il comportamento attuale è pinnato da un test
|
||||
dedicato in `palloni-core.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Versionare il cron (es. una migration con `cron.schedule`) invece di configurarlo solo
|
||||
lato dashboard.
|
||||
- Escludere dalla rotazione chi ha già dichiarato assenza per l'evento.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Modulo — Presenze
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
|
||||
`src/components/crapp/EventoCard.tsx`, `src/routes/api/public/sollecita-presenze.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Permettere a ogni giocatore di confermare o rifiutare la propria partecipazione a un evento
|
||||
(allenamento o partita) e mostrare a tutta la squadra chi ha risposto e come, sostituendo i
|
||||
solleciti a voce o su chat esterne.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Tabella `risposte_presenze` (PK composita `evento_id, giocatore_id`), letta e scritta da
|
||||
`src/lib/presenze.ts`. È il modello "in uso" citato in `docs/DATABASE.md`; le tabelle
|
||||
`eventi`/`presenze` previste da DD-014 non sono referenziate da nessun punto del codice
|
||||
attuale.
|
||||
|
||||
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
|
||||
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle
|
||||
statistiche. L'assenza di una riga per `(evento, giocatore)` equivale a "non ha ancora
|
||||
risposto". Lo stato `infortunato` è offerto solo per partite e allenamenti: sugli eventi
|
||||
extra-campo (`tipo === "evento"`, es. cena di squadra) non è pertinente e resta escluso
|
||||
ovunque — badge di riepilogo, gruppi elenco e bottoni di risposta — sia nella card
|
||||
(`EventoCard`, tramite `includeInfortunato()` in `presenze.ts`) sia nella scheda di dettaglio
|
||||
(`RosaPresenze`, prop `tipo`).
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
```
|
||||
Giocatore tocca uno stato in RosaPresenze
|
||||
↓
|
||||
useSalvaPresenza() → src/lib/presenze.ts (upsert o delete su risposte_presenze,
|
||||
↓ onConflict evento_id+giocatore_id)
|
||||
risposte_presenze (Supabase)
|
||||
↓ letta da
|
||||
useRispostePresenze() → src/lib/presenze.ts (1 query per sessione, staleTime 5 min,
|
||||
↓ legge tutta la tabella)
|
||||
RosaPresenze → src/components/crapp/RosaPresenze.tsx
|
||||
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato;
|
||||
allenamento.$id.tsx / prop `tipo` esclude "infortunato" per gli eventi extra-campo)
|
||||
partita.$id.tsx / evento.$id.tsx
|
||||
|
||||
EventoCard → src/components/crapp/EventoCard.tsx
|
||||
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
|
||||
|
||||
--- statistiche ---
|
||||
contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
|
||||
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
|
||||
(percentuale ultimi 30gg, da cache già in memoria)
|
||||
|
||||
contaPresenzeGiocatore() alimenta il campo `presenze` del `Giocatore` in `useRosa()`, mostrato
|
||||
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
|
||||
|
||||
contaPartiteGiocate() è contaPresenzeGiocatore() ristretto alle sole partite (non
|
||||
allenamenti): alimenta `Giocatore.partiteGiocate`, usato nel sottotitolo della classifica
|
||||
interna di Squadra quando si ordina per MVP — un conteggio di eventi generico (allenamenti
|
||||
compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
|
||||
|
||||
--- sollecito (solo admin) ---
|
||||
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
|
||||
↓
|
||||
src/routes/api/public/sollecita-presenze.ts
|
||||
├─ legge l'evento (eventi_app, con i convocati) e le risposte già date
|
||||
├─ destinatariSollecito() → src/lib/presenze.ts
|
||||
│ (convocati, o tutta la rosa se vuoti, senza risposta o con "forse"; funzione pura)
|
||||
├─ testoSollecito() per ciascuno (risposta attuale e «Richiesta di»)
|
||||
├─ per ciascuno invia una push col testo cifrato nel payload
|
||||
│ (src/lib/webpush.server.ts)
|
||||
└─ elimina le iscrizioni push scadute (404/410)
|
||||
```
|
||||
|
||||
> **Sollecito automatico (DD-040, migration `m26`).** Il pulsante «Sollecita» e la route
|
||||
> **restano**, ma `destinatariSollecito()` passa a guardare anche i convocati (convocati che non hanno
|
||||
> risposto o hanno risposto «forse», o tutta la rosa se `convocati` è vuoto); in più un job `pg_cron` manda il sollecito **una sola volta, 24 ore prima** dell'evento (DD-045, migration `m30`) 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). Testo e finestre nel [catalogo](notifiche.md#catalogo-delle-notifiche).
|
||||
> Il diagramma sopra è quello del pulsante, che da DD-040 legge anche `convocati` dell'evento.
|
||||
|
||||
Un evento conta ai fini delle statistiche di presenza solo se è di tipo `partita` o
|
||||
`allenamento` e il giocatore è tra i convocati (o non ci sono convocati specificati, cioè
|
||||
vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
|
||||
server, la UI risponde subito.
|
||||
- Il sollecito ha due vie (DD-040): un sollecito **automatico** (24 ore prima, job `pg_cron`
|
||||
nel database) e il pulsante **manuale** accanto, che non sostituiscono. La route del pulsante verifica
|
||||
il ruolo lato server con `richiediGestoreEventi` (admin o allenatore, DD-024, DD-034).
|
||||
- Ognuno risponde **solo per sé**, e non è più una regola della sola interfaccia: dalla
|
||||
migration `m11_scritture_per_ruolo` la policy di `risposte_presenze` lega la riga allo slot
|
||||
`giocatori_squadra` collegato all'account, con gli amministratori come sola deroga
|
||||
(DD-023). Verificato da `test/integration/permessi.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Nessuna finestra temporale per rispondere: si può cambiare risposta anche a evento passato.
|
||||
- La risposta di un evento passato resta modificabile: `risposte_presenze` non ha una
|
||||
finestra di chiusura, né in UI né in RLS.
|
||||
- `useRispostePresenze()` legge sempre l'intera tabella, non filtrata per evento: adeguato per
|
||||
una singola squadra, da rivedere se il volume cresce molto.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Filtrare la lettura delle presenze per evento invece di caricare tutta la tabella.
|
||||
@@ -0,0 +1,412 @@
|
||||
# Modulo — Profilo Giocatore
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/profili.ts`, `src/lib/profili-core.ts`, `src/routes/profilo.tsx`,
|
||||
`src/routes/admin.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
|
||||
|
||||
L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al giocatore sia agli amministratori, eliminando la gestione tramite chat, documenti cartacei e fogli Excel.
|
||||
|
||||
## Utenti
|
||||
|
||||
L'allenatore ha un profilo ridotto (solo Docs con i dati personali essenziali e Opzioni):
|
||||
la specifica sta in [allenatore.md](allenatore.md).
|
||||
|
||||
### Giocatore
|
||||
|
||||
Può:
|
||||
|
||||
- visualizzare il proprio profilo
|
||||
- modificare i propri dati personali
|
||||
- aggiornare il certificato medico
|
||||
- aggiornare i documenti
|
||||
- caricare le immagini richieste
|
||||
|
||||
### Amministratore
|
||||
|
||||
Può:
|
||||
|
||||
- visualizzare il profilo di tutti i giocatori
|
||||
- scaricare documenti e certificati
|
||||
- esportare i dati necessari al tesseramento CSI
|
||||
- verificare lo stato di completamento dei profili
|
||||
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email)
|
||||
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
|
||||
- scollegare un account da un profilo, liberando lo slot
|
||||
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale),
|
||||
oppure un allenatore (tipo «Allenatore»: niente numero né ruolo, DD-034)
|
||||
- disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la
|
||||
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
|
||||
agganciati al suo id
|
||||
|
||||
Non può caricare o sostituire i file altrui: documento e certificato restano
|
||||
responsabilità del giocatore che li fornisce.
|
||||
|
||||
## Flusso utente
|
||||
|
||||
### Primo accesso
|
||||
|
||||
1. Login tramite Google oppure Email. _Implementato con il solo Google: la squadra ha tutti
|
||||
un account Google, e un secondo metodo è additivo (un bottone in più sulla stessa
|
||||
schermata) il giorno che serve._
|
||||
2. Collegamento automatico al proprio giocatore, confrontando l'email dell'account Google
|
||||
con l'email registrata in `giocatori_squadra` (DD-018). Nessuna scelta manuale: se
|
||||
l'email non corrisponde a nessun profilo, l'accesso si ferma con un messaggio che invita
|
||||
a contattare un amministratore.
|
||||
3. Accesso alla Home.
|
||||
|
||||
Se il profilo non è completo compare automaticamente un widget di completamento.
|
||||
|
||||
## Home
|
||||
|
||||
Il giocatore visualizza un widget dedicato e, quando serve, l'avviso sul certificato medico.
|
||||
|
||||
### Completa il tuo profilo
|
||||
|
||||
Viene mostrata una barra di avanzamento (esempio: _Profilo completato — 85%_), composta dalle seguenti sezioni.
|
||||
|
||||
- Dati personali
|
||||
- Documento di identità
|
||||
- Certificato medico
|
||||
|
||||
Quando tutte le sezioni sono complete il widget scompare automaticamente.
|
||||
|
||||
Il tap apre Profilo sulla sottosezione **Documenti** (`/profilo?tab=documenti`), non
|
||||
sulla tab Stagione.
|
||||
|
||||
### Avviso certificati
|
||||
|
||||
**Stato:** implementato (DD-035, soglia del giocatore DD-041).
|
||||
|
||||
Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne
|
||||
accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza
|
||||
già salvata: niente tabella, niente migration, niente push.
|
||||
|
||||
#### Chi lo vede
|
||||
|
||||
| Utente | Cosa vede | Al tocco |
|
||||
| --------------------------- | ------------------------------------------------------------------------------ | ---------------------------------------- |
|
||||
| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
|
||||
| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
|
||||
| Admin che è anche giocatore | **entrambi**: lo staff (senza il suo nome) e il proprio personale, a 30 giorni | personale: apre `/profilo?tab=documenti` |
|
||||
| Altri giocatori | niente | — |
|
||||
| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
|
||||
|
||||
L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il
|
||||
certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore
|
||||
riceve già il proprio avviso. Quello personale porta invece dove si carica il certificato
|
||||
nuovo, come il widget «Completa il tuo profilo».
|
||||
|
||||
I certificati sono dati sanitari: fuori da `/admin` li legge solo il titolare (DD-016).
|
||||
Nessuna policy cambia, perché `useProfili()` restituisce già tutti i profili a un admin e il
|
||||
solo proprio a un giocatore.
|
||||
|
||||
#### Quando compare
|
||||
|
||||
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale). La
|
||||
soglia del giallo dipende da chi guarda (DD-041): il giocatore è avvisato un mese prima dello
|
||||
staff.
|
||||
|
||||
| Chi guarda | Soglia |
|
||||
| ---------- | ------------------------------------------ |
|
||||
| Giocatore | `GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30` |
|
||||
| Admin | `GIORNI_AVVISO_CERTIFICATO = 7` |
|
||||
|
||||
| Condizione | Stato | Avviso |
|
||||
| --------------------- | ----------- | ------------------------------ |
|
||||
| `giorni > soglia` | valido | nessuno |
|
||||
| `0 ≤ giorni ≤ soglia` | in scadenza | giallo (`warning`) |
|
||||
| `giorni < 0` | scaduto | nero (`primary`) |
|
||||
| data o file mancanti | mancante | rosso (`destructive`) (DD-046) |
|
||||
|
||||
Esempio: scadenza il 5 ottobre 2026. Dal 5 settembre l'avviso giallo compare solo al
|
||||
giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi.
|
||||
|
||||
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
|
||||
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
|
||||
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
|
||||
- Il certificato **mancante** (nessuna data o nessun file) ha un avviso rosso, più grave dello
|
||||
scaduto (DD-046): il giocatore vede «Certificato mancante» (cliccabile, porta ai documenti),
|
||||
l'admin la card «Certificati mancanti» con l'elenco, in cima alle altre, senza il proprio
|
||||
nome. Non ha soglie: resta finché il file non è caricato.
|
||||
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
|
||||
cioè `attivo` e `tipo = 'giocatore'`).
|
||||
|
||||
#### Testi
|
||||
|
||||
| Avviso | Titolo (staff / personale) | Riga |
|
||||
| ----------------- | -------------------------- | ------------------------------------------------------------------------ |
|
||||
| Giallo, staff | «Certificati in scadenza» | «Mario Rossi — scade tra 5 giorni», «… — scade domani», «… — scade oggi» |
|
||||
| Nero, staff | «Certificati scaduti» | «Mario Rossi — scaduto il 12/09/2026» |
|
||||
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
|
||||
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
|
||||
|
||||
Il numero di giorni scende da solo ogni giorno (30… 7, 6, 5… domani, oggi): l'avviso non
|
||||
conserva niente, rilegge la data a ogni apertura della Home.
|
||||
|
||||
#### Più giocatori
|
||||
|
||||
- Più giocatori nello stesso stato stanno in un **unico avviso**, una riga ciascuno, e
|
||||
compaiono **tutti**: nessun limite al numero di nomi, anche a inizio stagione quando ne
|
||||
scadono molti insieme.
|
||||
- Se ci sono sia certificati in scadenza sia scaduti compaiono **due avvisi**: prima quello
|
||||
nero, poi quello giallo.
|
||||
- Ordine delle righe: dalla scadenza più vicina nel giallo, dal certificato scaduto da più
|
||||
tempo nel nero; a parità di data, alfabetico per cognome e poi per nome. L'ordine è
|
||||
stabile, quindi l'elenco non si rimescola tra un'apertura e l'altra.
|
||||
|
||||
#### Aspetto e posizione
|
||||
|
||||
- Stessa forma del banner palloni (`PromemoriaPalloni`): card arrotondata a tutta larghezza
|
||||
con icona e titolo in `font-display` maiuscolo, righe in testo piccolo sotto.
|
||||
- Giallo: sfondo `bg-warning`, testo `text-warning-foreground`. Nero: sfondo `bg-primary`,
|
||||
testo `text-primary-foreground`. L'app è solo chiara (DD-022), quindi il nero non rischia
|
||||
di confondersi con lo sfondo.
|
||||
- In Home sta subito **sotto il banner palloni** e sopra «Completa il tuo profilo»: prima
|
||||
ciò che scade oggi, poi ciò che manca.
|
||||
|
||||
#### Rinnovo
|
||||
|
||||
Il giocatore rinnova dal proprio profilo, sottosezione Documenti: carica il file nuovo e
|
||||
aggiorna la data. Il salvataggio aggiorna la cache dei profili (`setQueryData`), quindi il
|
||||
suo avviso personale sparisce subito. Nell'avviso dello staff il nome sparisce alla lettura
|
||||
successiva dei profili da parte dell'admin (cache di 30 minuti, `useProfili()`), senza
|
||||
nessuna azione da parte sua. Quando non resta nessuno, l'avviso non compare più.
|
||||
|
||||
Basta aggiornare la data: l'avviso guarda solo quella (e la presenza di un file). Che il
|
||||
file caricato sia davvero quello nuovo resta un controllo dell'admin, come oggi. Anche l'admin
|
||||
può correggere la data dalla scheda del giocatore (DD-017, non il file): l'avviso segue la
|
||||
data, chiunque l'abbia scritta.
|
||||
|
||||
#### Caricamento
|
||||
|
||||
Finché ruoli e profili non sono arrivati, l'avviso non compare: così un admin che è anche
|
||||
giocatore non vede per un attimo l'avviso personale prima di quello dello staff. Se la
|
||||
lettura dei profili fallisce l'avviso semplicemente non compare, senza messaggio d'errore:
|
||||
la Home non deve rompersi per un dato accessorio.
|
||||
|
||||
#### Implementazione
|
||||
|
||||
| Pezzo | Ruolo |
|
||||
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia dello staff, 7 |
|
||||
| `GIORNI_AVVISO_CERTIFICATO_GIOCATORE` | la soglia del giocatore, 30 (DD-041) |
|
||||
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
|
||||
| `avvisiCertificati(rosa, profili, oggi, soglia)` | funzione pura (`soglia` di default 7): restituisce `{ scaduti, inScadenza, mancanti }`; le prime due sono liste ordinate di `{ giocatoreId, nome, cognome, scadenza, giorni }`, `mancanti` di `{ giocatoreId, nome, cognome }` (DD-046) |
|
||||
| `avvisiCertificatiUtente(rosa, profili, oggi, utente)` | funzione pura: `{ personale, personaleMancante, staff }` per chi guarda; l'admin giocatore ha entrambi, senza il suo nome nello staff (DD-041) |
|
||||
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
|
||||
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
|
||||
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
|
||||
|
||||
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
|
||||
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
|
||||
- Nessuna query nuova: `useProfili()` ha la stessa chiave di cache usata da Profilo e
|
||||
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
|
||||
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
|
||||
i profili.
|
||||
- Test in `test/unit/profili-core.test.ts`: avvisi per utente (admin giocatore, admin puro, giocatore semplice), soglia dello staff (8 giorni no, 7 sì, 0 sì, −1 scaduto) e del giocatore (31 no, 30 sì),
|
||||
certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi,
|
||||
ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e
|
||||
di anno nel calcolo dei giorni.
|
||||
|
||||
## Profilo
|
||||
|
||||
Il profilo viene suddiviso in sette aree.
|
||||
|
||||
### Dati Giocatore
|
||||
|
||||
**Dati squadra** — solo lettura, gestiti esclusivamente dagli amministratori.
|
||||
|
||||
- Nome
|
||||
- Cognome
|
||||
- Numero di maglia
|
||||
- Ruolo
|
||||
|
||||
**Dati personali** — modificabili dal giocatore.
|
||||
|
||||
- Data di nascita — oltre a `profili_giocatore.data_nascita`, un trigger la sincronizza in
|
||||
`giocatori_squadra.nascita` (DD-031): a differenza degli altri campi di questa sezione,
|
||||
visibile a **tutta la squadra**, non solo al giocatore stesso o all'admin (alimenta
|
||||
Squadra e i compleanni nel Calendario, vedi `squadra.md`). Cancellare il profilo azzera
|
||||
anche quel valore pubblico.
|
||||
- Luogo di nascita
|
||||
- Indirizzo di residenza
|
||||
- Telefono
|
||||
- Email
|
||||
|
||||
### Documento di identità
|
||||
|
||||
Campi.
|
||||
|
||||
- Tipo documento
|
||||
- Numero documento
|
||||
- Rilasciato da
|
||||
- Data emissione
|
||||
- Data scadenza
|
||||
|
||||
Upload.
|
||||
|
||||
- Foto fronte
|
||||
- Foto retro
|
||||
|
||||
### Certificato medico
|
||||
|
||||
Campi.
|
||||
|
||||
- Data di scadenza
|
||||
|
||||
Upload.
|
||||
|
||||
- Certificato medico
|
||||
|
||||
Il giocatore può aggiornare liberamente sia la data sia il file.
|
||||
|
||||
Quando mancano 30 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
|
||||
riceve un avviso nella propria Home; compare in quello degli admin da 7 giorni prima (vedi
|
||||
[Avviso certificati](#avviso-certificati)).
|
||||
|
||||
Lo storico non viene mantenuto nella prima versione.
|
||||
|
||||
### Foto tessera
|
||||
|
||||
**Rimossa** (DD-044, migration `m29`): il profilo non chiede più la foto tessera e la colonna
|
||||
`foto_path` non esiste più. I file già caricati sono stati cancellati dal bucket.
|
||||
|
||||
### Statistiche
|
||||
|
||||
Sezione già presente. Contiene.
|
||||
|
||||
- Presenze
|
||||
- Voto medio
|
||||
- MVP
|
||||
- Serie
|
||||
- Altre statistiche disponibili
|
||||
|
||||
### Badge
|
||||
|
||||
Sezione già presente.
|
||||
|
||||
Contiene tutti i badge ottenuti e quelli ancora da sbloccare.
|
||||
|
||||
### Opzioni
|
||||
|
||||
Contiene.
|
||||
|
||||
- Logout
|
||||
- Preferenze notifiche: un solo interruttore che iscrive il dispositivo a **tutte** le push
|
||||
(turno palloni, solleciti presenze) e abilita le notifiche smart in app — non è limitato
|
||||
ai soli palloni (vedi [Notifiche](notifiche.md))
|
||||
- Impostazioni applicazione
|
||||
- Segnala un bug e Suggerisci una nuova funzionalità: due link che aprono una issue GitHub
|
||||
già impostata sul template giusto (`.github/ISSUE_TEMPLATE/bug_report.yml` e
|
||||
`feature_request.yml`). Nessun dato passa dall'app — la segnalazione vive interamente su
|
||||
GitHub, così non servono né una tabella né una schermata di gestione.
|
||||
|
||||
## Dashboard amministratore
|
||||
|
||||
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
|
||||
Profilo → Opzioni), organizzata in tab scorrevoli a pillole (`BarraSottosezioni`, stesso
|
||||
componente di [Squadra](squadra.md) e Campionato): Squadra, Profili, Disattivati (solo se
|
||||
c'è almeno un giocatore disattivato) e Notifiche.
|
||||
|
||||
La tab **Notifiche** mostra quanti giocatori attivi hanno almeno un dispositivo iscritto
|
||||
alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (vedi
|
||||
[Notifiche](notifiche.md)). È solo consultiva: l'attivazione resta un gesto che ogni
|
||||
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
|
||||
di altri.
|
||||
|
||||
Gli allenatori stanno nella stessa tab in un gruppo «Allenatori» a parte, senza documenti né
|
||||
tesseramento, e non entrano nei conteggi della tab Squadra né nell'export CSI (DD-034).
|
||||
|
||||
Per ogni giocatore, nella tab Profili, vengono mostrati.
|
||||
|
||||
- Stato del profilo
|
||||
- Certificato medico
|
||||
- Documento di identità
|
||||
- Stato tesseramento CSI (tesserato / da tesserare)
|
||||
|
||||
Azioni disponibili.
|
||||
|
||||
- Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata)
|
||||
- Scarica certificato
|
||||
- Scarica documento
|
||||
- Modifica dati squadra e dati personali del giocatore (DD-017)
|
||||
- Registra numero e data della tessera CSI, una volta arrivata dal comitato
|
||||
- Scollega account, per liberare uno slot assegnato per errore
|
||||
- Aggiungi giocatore, per inserire un nuovo membro della squadra
|
||||
- Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra)
|
||||
|
||||
## Esportazione CSI
|
||||
|
||||
Gli amministratori possono esportare un file CSV contenente esclusivamente i dati richiesti per il tesseramento.
|
||||
|
||||
Campi esportati.
|
||||
|
||||
- Nome
|
||||
- Cognome
|
||||
- Data di nascita
|
||||
- Luogo di nascita
|
||||
- Indirizzo
|
||||
- Telefono
|
||||
- Email
|
||||
- Tipo documento
|
||||
- Numero documento
|
||||
- Rilasciato da
|
||||
- Data emissione
|
||||
- Data scadenza
|
||||
|
||||
## Tracciamento tesseramento
|
||||
|
||||
Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano
|
||||
dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del
|
||||
profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo
|
||||
(DD-017), il trigger sulla tabella li rende non modificabili dal giocatore stesso. La
|
||||
dashboard mostra un badge "Tesserato"/"Da tesserare" su ogni scheda e il conteggio
|
||||
complessivo della squadra.
|
||||
|
||||
## Completamento profilo
|
||||
|
||||
Ogni sezione contribuisce alla percentuale di completamento.
|
||||
|
||||
| Sezione | Peso |
|
||||
| --------------------- | ---- |
|
||||
| Dati personali | 34% |
|
||||
| Documento di identità | 33% |
|
||||
| Certificato medico | 33% |
|
||||
|
||||
Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
|
||||
|
||||
## Permessi
|
||||
|
||||
**Giocatore** — può modificare esclusivamente il proprio profilo.
|
||||
|
||||
**Amministratore** — può visualizzare tutti i profili, scaricare tutti i documenti, esportare i
|
||||
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
|
||||
Non carica file al posto di altri.
|
||||
|
||||
**Avviso certificati** — il giocatore vede solo il proprio, l'admin i nomi di tutta la rosa,
|
||||
nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
|
||||
|
||||
## Versione 1
|
||||
|
||||
- Profilo giocatore
|
||||
- Completamento profilo
|
||||
- Gestione dati personali
|
||||
- Documento di identità
|
||||
- Certificato medico
|
||||
- Dashboard amministratore
|
||||
- Esportazione CSV CSI
|
||||
|
||||
## Versioni future
|
||||
|
||||
- Storico certificati medici
|
||||
- Avviso certificati anche via push, o con una soglia regolabile dall'admin (DD-035, Riesame)
|
||||
- Gestione documenti aggiuntivi
|
||||
- Consensi privacy
|
||||
- Firma digitale
|
||||
- Verifica automatica documenti
|
||||
Loaded 100 of 445 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user