Separazione: le aree riservate diventano un'app propria (apps/platforms)
Campus, Stress Index e Longevity escono dal sito e diventano apps.insanitylab.it, con utenti, sessioni, login e database propri. Deciso da Adriano il 03/09/2026: una repo sola, due applicazioni autosufficienti, cosi' che ognuna resti estraibile. Misura che ha guidato il taglio: le tre aree condividevano col sito SOLO l'identita' - ~170 righe fra auth.ts, middleware.ts e safe-next.ts. Layout, librerie e dati erano gia' disgiunti. Lato piattaforme: - ruoli piattaforme/cliente/trainer/admin, senza quelli redazionali - canAccessPath con default CHIUSO (nel sito era aperto: li' le rotte non elencate erano di chiunque fosse loggato, qui ogni sezione e' di qualcuno) - tutta l'app protetta, si elencano le eccezioni invece dei prefissi protetti - /api risponde 401/403, non un redirect verso una pagina di login - tre database distinti: utenti, dati clinici senza nomi, mappatura coi nomi Lato sito: - ROLES tornano i tre redazionali; via le regole e il login pubblico - il login del pannello RESPINGE un account emigrato invece di aprirgli una sessione: senza, landingFor lo mandava su /admin, il middleware glielo negava, e si formava un ciclo di redirect che sembra un guasto del sito - l'header non mostra piu' un menu delle piattaforme: un link che esce Verificato: sito 20 file / 146 test, piattaforme 26 file / 197 test, entrambi i build passano, e l'app girata davvero in locale - il ruolo piattaforme entra in Campus e viene rimbalzato da Longevity, il cliente il contrario. Non ancora in produzione: manca il record DNS di apps.insanitylab.it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
---
|
||||
import { site } from '../data/site';
|
||||
import { piattaforme } from '../data/piattaforme';
|
||||
import { t, contentImageUrl } from '../lib/content';
|
||||
import { landingFor } from '../lib/auth';
|
||||
interface Props {
|
||||
@@ -10,11 +9,11 @@ interface Props {
|
||||
}
|
||||
const { user, canTags, showTags } = Astro.props;
|
||||
const path = Astro.url.pathname;
|
||||
const loginHref = `/login?next=${encodeURIComponent(path)}`;
|
||||
// ⚠️ Le aree riservate (Campus, Stress Index, Longevity) dal 03/09/2026 NON sono in questo
|
||||
// sito: vivono su apps.insanitylab.it, con utenti e sessioni propri. Qui resta un link che
|
||||
// esce, non un menu — questo sito non sa più chi possa aprirle, ed è la separazione voluta.
|
||||
const AREA_RISERVATA = 'https://apps.insanitylab.it';
|
||||
const areaHref = user ? landingFor(user.role) : '/admin';
|
||||
// Le piattaforme riservate compaiono nel menu solo a chi può davvero aprirle.
|
||||
const canPiattaforme = user?.role === 'piattaforme' || user?.role === 'admin';
|
||||
const inPiattaforme = piattaforme.some((p) => path === p.href || path.startsWith(`${p.href}/`));
|
||||
---
|
||||
<header class="hdr" id="site-header">
|
||||
<div class="container hdr__in">
|
||||
@@ -25,16 +24,6 @@ const inPiattaforme = piattaforme.some((p) => path === p.href || path.startsWith
|
||||
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
|
||||
)
|
||||
))}
|
||||
{canPiattaforme && (
|
||||
<details class="plat">
|
||||
<summary class:list={['hdr__link', 'hdr__link--bio', 'plat__btn', { 'is-active': inPiattaforme }]}>Piattaforme ▾</summary>
|
||||
<div class="plat__panel">
|
||||
{piattaforme.map((p) => (
|
||||
<a href={p.href} class:list={['plat__item', { 'is-active': path === p.href || path.startsWith(`${p.href}/`) }]}>{p.label}</a>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</nav>
|
||||
<div class="hdr__user">
|
||||
{user ? (
|
||||
@@ -49,7 +38,7 @@ const inPiattaforme = piattaforme.some((p) => path === p.href || path.startsWith
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<a class="hdr__login" href={loginHref}>Accedi</a>
|
||||
<a class="hdr__login" href={AREA_RISERVATA}>Area riservata</a>
|
||||
)}
|
||||
</div>
|
||||
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
// La carta di un macro-score (Performance/Energy/Recovery). Stesso principio del radar:
|
||||
// il numero se il dato basta, un trattino se no — MAI uno zero, che si leggerebbe come
|
||||
// "hai zero" invece di "non lo sappiamo ancora". Il bordo tratteggiato (.lg-card--incompleto,
|
||||
// dal Task 1) è lo stesso linguaggio visivo del radar: non una convenzione nuova per le carte.
|
||||
// L'etichetta si deriva dal nome del contenitore (dato, letto da vista.ts), non da un
|
||||
// elenco scritto qui: PERFORMANCE/ENERGY/RECOVERY diventano Performance/Energy/Recovery
|
||||
// con una trasformazione, non con un dizionario da tenere allineato a mano.
|
||||
import { formattaNumero } from '../../lib/longevity/formato';
|
||||
|
||||
interface Props {
|
||||
nome: string;
|
||||
valore: number | null;
|
||||
copertura: number;
|
||||
}
|
||||
const { nome, valore, copertura } = Astro.props;
|
||||
const etichetta = nome.charAt(0) + nome.slice(1).toLowerCase();
|
||||
// L'intensità dell'accento (mai un colore diverso, Task 1): minimo 0.15 così una carta
|
||||
// nota ma bassa resta comunque leggibile, non sparisce nello sfondo.
|
||||
const intensita = valore !== null ? Math.max(valore / 100, 0.15) : 0;
|
||||
---
|
||||
<div class:list={['lg-card', 'lg-macro__card', { 'lg-card--incompleto': valore === null }]}>
|
||||
<p class="lg-score__label">{etichetta}</p>
|
||||
<div class="lg-score">
|
||||
<span class="lg-score__value lg-num">{valore !== null ? formattaNumero(valore) : '—'}</span>
|
||||
<span class="lg-score__max">/ 100</span>
|
||||
</div>
|
||||
<div class="lg-score__bar">
|
||||
<span
|
||||
class="lg-score__bar-fill"
|
||||
style={`width:${valore !== null ? valore : 0}%; --lg-intensita:${intensita}`}
|
||||
></span>
|
||||
</div>
|
||||
<p class="lg-muted lg-macro__nota">
|
||||
Copertura dati: {Math.round(copertura * 100)}%{valore === null && ' — non ancora sufficiente per un numero.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,192 +0,0 @@
|
||||
// Il questionario in pagina: quattro schermate — le tre aree di domande punteggiate,
|
||||
// lette dal registro (props `campi`, costruite da `campiDelQuestionario`), più le note
|
||||
// libere e il consenso. Avanti/indietro fra i blocchi, invio solo sull'ultimo.
|
||||
//
|
||||
// Il consenso qui NON blocca lato client la scelta "No": bloccarlo qui ripeterebbe
|
||||
// l'errore del prototipo del cliente in un altro punto (un controllo che sembra
|
||||
// proteggere e non protegge). La verifica che conta è quella del server
|
||||
// (src/lib/longevity/vista.ts, salvaDalForm): se il consenso non è affermativo, la
|
||||
// compilazione non si salva e la risposta lo dice — questa isola si limita a mostrarlo.
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { CampoQuestionario } from '../../lib/longevity/vista';
|
||||
|
||||
interface Props {
|
||||
campi: CampoQuestionario[];
|
||||
}
|
||||
|
||||
const CAMPI_LIBERI: { id: string; etichetta: string }[] = [
|
||||
{ id: 'farmaci', etichetta: 'Farmaci assunti abitualmente' },
|
||||
{ id: 'problematiche_attuali', etichetta: 'Problematiche di salute attuali' },
|
||||
{ id: 'problematiche_pregresse', etichetta: 'Problematiche di salute pregresse' },
|
||||
{ id: 'obiettivi', etichetta: 'Obiettivi del percorso' },
|
||||
];
|
||||
|
||||
const BLOCCHI = [
|
||||
{ titolo: 'Sonno & Recupero', sottoDominio: 'questionario_sonno' },
|
||||
{ titolo: 'Energia & Stress', sottoDominio: 'questionario_energia_stress' },
|
||||
{ titolo: 'Stile di Vita', sottoDominio: 'questionario_lifestyle' },
|
||||
] as const;
|
||||
|
||||
type Esito = { tipo: 'ok'; sessioneId: number } | { tipo: 'errore'; messaggio: string };
|
||||
|
||||
export default function Questionario({ campi }: Props) {
|
||||
const [passo, setPasso] = useState(0);
|
||||
const [risposte, setRisposte] = useState<Record<string, string>>({});
|
||||
const [liberi, setLiberi] = useState<Record<string, string>>({});
|
||||
const [consenso, setConsenso] = useState<'' | 'Si' | 'No'>('');
|
||||
const [eta, setEta] = useState('');
|
||||
const [inviando, setInviando] = useState(false);
|
||||
const [esito, setEsito] = useState<Esito | null>(null);
|
||||
|
||||
// Le domande arrivano dal registro in ordine di test_id, non raggruppate: qui si
|
||||
// ricostruiscono i tre blocchi filtrando sul sotto-dominio, senza toccare i dati.
|
||||
const gruppi = useMemo(
|
||||
() => BLOCCHI.map((b) => ({ ...b, campi: campi.filter((c) => c.sottoDominio === b.sottoDominio) })),
|
||||
[campi]
|
||||
);
|
||||
|
||||
// Il tipo dichiarato nel registro per ciascun campo: serve sia per scegliere il tipo di
|
||||
// input (numero o testo) sia, all'invio, per non trattare una risposta testuale come un
|
||||
// numero da scartare in silenzio.
|
||||
const tipoDiCampo = useMemo(() => new Map(campi.map((c) => [c.id, c.tipo])), [campi]);
|
||||
|
||||
const totalePassi = gruppi.length + 1; // + il blocco note/consenso
|
||||
const ultimo = passo === totalePassi - 1;
|
||||
|
||||
async function invia() {
|
||||
setInviando(true);
|
||||
setEsito(null);
|
||||
|
||||
const risposteNumeriche: Record<string, number> = {};
|
||||
for (const [id, v] of Object.entries(risposte)) {
|
||||
// Le domande testuali (tipo_valore='txt' nel registro) non sono ancora agganciate
|
||||
// al salvataggio numerico: oggi non esistono nel questionario (i 20 campi attivi
|
||||
// sono tutti 'num'), ma se un domani ne comparisse una non va convertita in NaN e
|
||||
// scartata in silenzio — resta fuori esplicitamente.
|
||||
if (tipoDiCampo.get(id) === 'txt') continue;
|
||||
const n = Number(v);
|
||||
if (v !== '' && Number.isFinite(n)) risposteNumeriche[id] = n;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/longevity/questionario', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
risposte: risposteNumeriche,
|
||||
liberi,
|
||||
consensoSanitario: consenso,
|
||||
eta: eta === '' ? undefined : Number(eta),
|
||||
}),
|
||||
});
|
||||
const corpo = await res.json();
|
||||
if (res.ok) setEsito({ tipo: 'ok', sessioneId: corpo.sessioneId });
|
||||
else setEsito({ tipo: 'errore', messaggio: corpo.error ?? 'Invio non riuscito.' });
|
||||
} catch {
|
||||
setEsito({ tipo: 'errore', messaggio: 'Invio non riuscito: verifica la connessione.' });
|
||||
} finally {
|
||||
setInviando(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (esito?.tipo === 'ok') {
|
||||
return (
|
||||
<div className="lg-card">
|
||||
<p>Questionario salvato, grazie.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lg-form">
|
||||
<p className="lg-form__passo">Passo {passo + 1} di {totalePassi}</p>
|
||||
|
||||
{passo < gruppi.length && (
|
||||
<section className="lg-form__blocco">
|
||||
<h2>{gruppi[passo].titolo}</h2>
|
||||
{gruppi[passo].campi.map((c) => (
|
||||
<label className="lg-field" key={c.id}>
|
||||
<span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
|
||||
{c.tipo === 'txt' ? (
|
||||
<input
|
||||
type="text"
|
||||
value={risposte[c.id] ?? ''}
|
||||
onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min={c.min}
|
||||
max={c.max}
|
||||
step="any"
|
||||
value={risposte[c.id] ?? ''}
|
||||
onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ultimo && (
|
||||
<section className="lg-form__blocco">
|
||||
<h2>Note e consenso</h2>
|
||||
|
||||
{CAMPI_LIBERI.map((c) => (
|
||||
<label className="lg-field" key={c.id}>
|
||||
<span>{c.etichetta}</span>
|
||||
<textarea
|
||||
value={liberi[c.id] ?? ''}
|
||||
onChange={(e) => setLiberi((l) => ({ ...l, [c.id]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<label className="lg-field">
|
||||
<span>La tua età</span>
|
||||
<input type="number" min={0} max={120} value={eta} onChange={(e) => setEta(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<fieldset className="lg-field lg-field--consenso">
|
||||
<legend>
|
||||
Acconsenti al trattamento dei tuoi dati sanitari (farmaci, patologie, pressione,
|
||||
composizione corporea) per il monitoraggio del percorso?
|
||||
</legend>
|
||||
<label>
|
||||
<input
|
||||
type="radio" name="consenso" checked={consenso === 'Si'}
|
||||
onChange={() => setConsenso('Si')}
|
||||
/>
|
||||
Sì, acconsento
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio" name="consenso" checked={consenso === 'No'}
|
||||
onChange={() => setConsenso('No')}
|
||||
/>
|
||||
No, non acconsento
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{esito?.tipo === 'errore' && <p className="lg-form__errore">{esito.messaggio}</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="lg-form__nav">
|
||||
<button type="button" disabled={passo === 0} onClick={() => setPasso((p) => Math.max(0, p - 1))}>
|
||||
Indietro
|
||||
</button>
|
||||
{!ultimo && (
|
||||
<button type="button" onClick={() => setPasso((p) => Math.min(totalePassi - 1, p + 1))}>
|
||||
Avanti
|
||||
</button>
|
||||
)}
|
||||
{ultimo && (
|
||||
<button type="button" disabled={inviando || consenso === ''} onClick={invia}>
|
||||
{inviando ? 'Invio…' : 'Invia'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Il radar a sette assi del referto: isola React perché recharts ha bisogno del DOM.
|
||||
// Due serie sovrapposte sugli stessi sette assi (recharts vuole un solo `data` condiviso,
|
||||
// non può disegnare un poligono che copre solo alcuni assi):
|
||||
// - "noto": l'area piena, coi valori congelati che la sessione ha davvero. Dove il dato
|
||||
// manca vale 0 — non si inventa un centro pieno per una misura che non c'è, si lascia
|
||||
// che l'area si ritiri, onestamente, verso il centro;
|
||||
// - "potenziale": il perimetro tratteggiato. Dove il dato c'è coincide col bordo di
|
||||
// "noto" (stesso valore, la linea tratteggiata sparisce dietro quella piena); dove
|
||||
// manca si allarga fino al massimo (100) — è la parte che CHIUDE il perimetro, il gesto
|
||||
// su cui si regge l'interfaccia: non un errore, un invito a completare quell'asse.
|
||||
// Sotto il grafico, l'elenco leggibile di cosa completerebbe ogni asse ancora tratteggiato,
|
||||
// letto da `mancano` (calcolato in vista.ts, mai qui: questo componente non tocca il db).
|
||||
import {
|
||||
ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar,
|
||||
} from 'recharts';
|
||||
import type { AsseVista } from '../../lib/longevity/vista';
|
||||
|
||||
interface Props {
|
||||
assi: AsseVista[];
|
||||
}
|
||||
|
||||
// Stessi colori del sito (come in DataChart.tsx, che per lo stesso motivo — attributi SVG,
|
||||
// non proprietà CSS — non passa var(--...) a recharts): l'accento e il tratteggio di
|
||||
// longevity.css, non un colore nuovo.
|
||||
const ACCENTO = '#9c8b70';
|
||||
const TRATTEGGIO = '#d8d2c8';
|
||||
const LINEA = '#e4dfd7';
|
||||
const TESTO_CHIARO = '#8a8a8a';
|
||||
|
||||
export default function RadarAssi({ assi }: Props) {
|
||||
const dati = assi.map((a) => ({
|
||||
nome: a.nome,
|
||||
noto: a.valore ?? 0,
|
||||
potenziale: a.valore ?? 100,
|
||||
}));
|
||||
const incompleti = assi.filter((a) => a.mancano.length > 0);
|
||||
|
||||
return (
|
||||
<div className="lg-radar">
|
||||
{/* La spiegazione del linguaggio visivo viene PRIMA del grafico che spiega, non dopo:
|
||||
chi legge deve sapere cosa sta guardando prima di guardarlo. */}
|
||||
<p className="lg-radar__intro">
|
||||
L'area piena è quello che sappiamo davvero; il perimetro tratteggiato è il resto
|
||||
dell'asse, dove il dato non c'è ancora.
|
||||
</p>
|
||||
<div className="lg-radar__grafico">
|
||||
<ResponsiveContainer width="100%" height={360}>
|
||||
<RadarChart data={dati} outerRadius="72%">
|
||||
<PolarGrid stroke={LINEA} />
|
||||
<PolarAngleAxis dataKey="nome" tick={{ fill: TESTO_CHIARO, fontSize: 11 }} />
|
||||
<PolarRadiusAxis domain={[0, 100]} tick={false} axisLine={false} />
|
||||
{/* il perimetro tratteggiato si disegna per primo: quello pieno gli va sopra */}
|
||||
<Radar
|
||||
name="Perimetro completo"
|
||||
dataKey="potenziale"
|
||||
stroke={TRATTEGGIO}
|
||||
strokeDasharray="4 3"
|
||||
strokeWidth={1.5}
|
||||
fill="none"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Radar
|
||||
name="Quello che sappiamo"
|
||||
dataKey="noto"
|
||||
stroke={ACCENTO}
|
||||
fill={ACCENTO}
|
||||
fillOpacity={0.32}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{incompleti.length > 0 && (
|
||||
<div className="lg-radar__mancano">
|
||||
{/* L'invito concreto: l'elenco sotto è nomi di test, da solo non dice cosa fare.
|
||||
Questi test si fanno in palestra con lo staff, non a casa da soli. */}
|
||||
<p className="lg-muted">
|
||||
Questi test si fanno in sala, con il trainer: prenota la prossima sessione per
|
||||
completare il quadro.
|
||||
</p>
|
||||
{incompleti.map((a) => (
|
||||
<div className="lg-radar__voce" key={a.nome}>
|
||||
<p className="lg-radar__voce-nome">{a.nome}</p>
|
||||
<ul className="lg-radar__lista">
|
||||
{a.mancano.map((m) => <li key={m}>{m}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
import type { Alert } from '../../lib/stress-index/data';
|
||||
interface Props { alert: Alert }
|
||||
const { alert } = Astro.props;
|
||||
---
|
||||
<div class:list={['si-alert', alert.severity === 'error' && 'si-alert--error']}>
|
||||
<span class="si-alert__mark" aria-hidden="true"></span>
|
||||
<div>
|
||||
<p class="si-alert__text">{alert.text}</p>
|
||||
<p class="si-alert__meta">{alert.clientName} · {alert.when}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,140 +0,0 @@
|
||||
// Analytics di studio. È un'isola React perché tutto dipende da due controlli — l'intervallo
|
||||
// di date e la dimensione di confronto — e ogni numero della pagina si ricalcola con essi.
|
||||
import { useMemo, useState } from 'react';
|
||||
import DataChart from './charts/DataChart';
|
||||
import type { Alert, Client, Measurement } from '../../lib/stress-index/data';
|
||||
import {
|
||||
DIMENSIONI, METRICHE, classificaRecupero, confrontoSegmenti, distribuzione, kpis,
|
||||
type Dimensione,
|
||||
} from '../../lib/stress-index/analytics';
|
||||
|
||||
interface Props {
|
||||
clients: Client[];
|
||||
measurementsByClient: Record<string, Measurement[]>;
|
||||
alerts: Alert[];
|
||||
/** Intervallo iniziale, deciso dalla pagina. */
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const dataItaliana = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00`).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
|
||||
export default function AnalyticsView({ clients, measurementsByClient, alerts, from, to }: Props) {
|
||||
const [periodo, setPeriodo] = useState({ from, to });
|
||||
const [dimensione, setDimensione] = useState<Dimensione>('sesso');
|
||||
|
||||
const indicatori = useMemo(() => kpis(clients, measurementsByClient, alerts, periodo), [clients, measurementsByClient, alerts, periodo]);
|
||||
const fasce = useMemo(() => distribuzione(measurementsByClient, periodo), [measurementsByClient, periodo]);
|
||||
const classifica = useMemo(() => classificaRecupero(clients, measurementsByClient, periodo), [clients, measurementsByClient, periodo]);
|
||||
const segmenti = useMemo(() => confrontoSegmenti(clients, measurementsByClient, periodo, dimensione), [clients, measurementsByClient, periodo, dimensione]);
|
||||
|
||||
const migliori = classifica.slice(0, 4);
|
||||
const critici = [...classifica].reverse().slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="si-stack">
|
||||
<div className="si-head si-head--row">
|
||||
<div>
|
||||
<h1>Analytics di studio</h1>
|
||||
<p>Confronta i tuoi clienti, individua gli andamenti, calibra il lavoro</p>
|
||||
</div>
|
||||
<div className="si-range">
|
||||
<label className="si-field si-field--inline">
|
||||
<span className="si-visually-hidden">Dal</span>
|
||||
<input type="date" value={periodo.from} max={periodo.to} onChange={(e) => setPeriodo((p) => ({ ...p, from: e.target.value }))} />
|
||||
</label>
|
||||
<span className="si-range__sep" aria-hidden="true">–</span>
|
||||
<label className="si-field si-field--inline">
|
||||
<span className="si-visually-hidden">Al</span>
|
||||
<input type="date" value={periodo.to} min={periodo.from} onChange={(e) => setPeriodo((p) => ({ ...p, to: e.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="si-grid si-grid--kpi">
|
||||
{indicatori.map((k) => (
|
||||
<div className="si-card" key={k.label}>
|
||||
<p className="si-kpi__label">{k.label}</p>
|
||||
<p className="si-kpi__value">{k.value}</p>
|
||||
<p className="si-muted si-small">{k.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Distribuzione score clienti</h2>
|
||||
<p className="si-muted si-small">Quante misurazioni cadono in ogni fascia di valore</p>
|
||||
</div>
|
||||
</div>
|
||||
<DataChart type="bar" data={fasce} xKey="range" series={METRICHE.map((m) => ({ ...m }))} height={300} />
|
||||
</section>
|
||||
|
||||
<div className="si-grid si-grid--halves">
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Andamento migliore</h2>
|
||||
<p className="si-muted si-small">Recupero medio più alto nel periodo</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="si-rank">
|
||||
{migliori.map((r) => (
|
||||
<li key={r.clientId}>
|
||||
<a href={`/piattaforme/stress-index/clienti/${r.clientId}`}>{r.name}</a>
|
||||
<span>
|
||||
<span className="si-rank__value si-rank__value--ok">{r.recovery}</span>
|
||||
<span className="si-rank__count">{r.count} {r.count === 1 ? 'misurazione' : 'misurazioni'}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Clienti critici</h2>
|
||||
<p className="si-muted si-small">Recupero medio più basso nel periodo</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="si-rank">
|
||||
{critici.map((r) => (
|
||||
<li key={r.clientId}>
|
||||
<a href={`/piattaforme/stress-index/clienti/${r.clientId}`}>{r.name}</a>
|
||||
<span>
|
||||
<span className="si-rank__value si-rank__value--bad">{r.recovery}</span>
|
||||
<span className="si-rank__count">{r.count} {r.count === 1 ? 'misurazione' : 'misurazioni'}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Confronto segmenti</h2>
|
||||
<p className="si-muted si-small">Valori medi per {dataItaliana(periodo.from)} – {dataItaliana(periodo.to)}</p>
|
||||
</div>
|
||||
<label className="si-field si-field--inline">
|
||||
<span className="si-visually-hidden">Dimensione di confronto</span>
|
||||
<select value={dimensione} onChange={(e) => setDimensione(e.target.value as Dimensione)}>
|
||||
{DIMENSIONI.map((d) => <option key={d.key} value={d.key}>{d.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<DataChart
|
||||
type="bar" data={segmenti} xKey="gruppo" height={300}
|
||||
series={[
|
||||
{ key: 'stress', label: 'Stress medio', color: '#b05a4e' },
|
||||
{ key: 'recovery', label: 'Recupero medio', color: '#6f8f6a' },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
// Riquadro base della dashboard: titolo opzionale e, a destra, un'azione (di solito un link).
|
||||
interface Props { title?: string; actionLabel?: string; actionHref?: string; class?: string }
|
||||
const { title, actionLabel, actionHref, class: cls } = Astro.props;
|
||||
---
|
||||
<section class:list={['si-card', cls]}>
|
||||
{(title || actionLabel) && (
|
||||
<div class="si-card__head">
|
||||
{title && <h2 class="si-card__title">{title}</h2>}
|
||||
{actionLabel && actionHref && <a class="si-card__action" href={actionHref}>{actionLabel}</a>}
|
||||
</div>
|
||||
)}
|
||||
<slot />
|
||||
</section>
|
||||
@@ -1,387 +0,0 @@
|
||||
// Scheda cliente: è l'unica vista davvero interattiva della piattaforma (tab, filtri sul
|
||||
// grafico, note, messaggi, tag), quindi vive come singola isola React. Lo stile arriva dalle
|
||||
// classi `.si-*` del foglio globale: qui non ci sono classi Tailwind del prototipo originale.
|
||||
import { useMemo, useState } from 'react';
|
||||
import DataChart from './charts/DataChart';
|
||||
import type { Alert, Client, Measurement, Note } from '../../lib/stress-index/data';
|
||||
|
||||
interface Props {
|
||||
client: Client;
|
||||
measurements: Measurement[];
|
||||
alerts: Alert[];
|
||||
notes: Note[];
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ key: 'panoramica', label: 'Panoramica' },
|
||||
{ key: 'misurazioni', label: 'Misurazioni' },
|
||||
{ key: 'analytics', label: 'Analytics' },
|
||||
{ key: 'report', label: 'Report' },
|
||||
{ key: 'note', label: 'Note' },
|
||||
{ key: 'messaggi', label: 'Messaggi' },
|
||||
{ key: 'impostazioni', label: 'Impostazioni' },
|
||||
];
|
||||
|
||||
const METRICHE = [
|
||||
{ key: 'stress', label: 'Stress', color: '#b05a4e' },
|
||||
{ key: 'recovery', label: 'Recupero', color: '#6f8f6a' },
|
||||
{ key: 'balance', label: 'Equilibrio', color: '#c08a3e' },
|
||||
{ key: 'energy', label: 'Energia', color: '#9c8b70' },
|
||||
];
|
||||
|
||||
const PERIODI = [
|
||||
{ key: '4', label: 'Ultime 4' },
|
||||
{ key: '8', label: 'Ultime 8' },
|
||||
{ key: 'all', label: 'Tutte' },
|
||||
];
|
||||
|
||||
const REPORT = [
|
||||
{ date: '2026-07-01', label: 'Report mensile — Giugno 2026' },
|
||||
{ date: '2026-06-01', label: 'Report mensile — Maggio 2026' },
|
||||
{ date: '2026-05-01', label: 'Report mensile — Aprile 2026' },
|
||||
];
|
||||
|
||||
const MESSAGGI_INIZIALI = [
|
||||
{ from: 'cliente', text: "Buongiorno dottore, le invio l'esito della misurazione di stamattina.", when: '09:12' },
|
||||
{ from: 'professionista', text: 'Grazie, la vedo. I valori di recupero sono in miglioramento rispetto alla scorsa settimana.', when: '09:20' },
|
||||
{ from: 'cliente', text: 'Sì, mi sento meglio, dormo di più.', when: '09:22' },
|
||||
{ from: 'professionista', text: 'Continui così, ci sentiamo al prossimo controllo.', when: '09:25' },
|
||||
];
|
||||
|
||||
const qualitativa = (v: number) => (v >= 70 ? 'Alto' : v >= 40 ? 'Moderato' : 'Basso');
|
||||
const livello = (v: number) => (v >= 70 ? 'bad' : v >= 45 ? 'warn' : 'ok');
|
||||
|
||||
/** Indicatore ad arco: il valore riempie un semicerchio da sinistra a destra. */
|
||||
function Gauge({ value, label }: { value: number; label: string }) {
|
||||
const ratio = Math.max(0, Math.min(1, value / 100));
|
||||
const R = 56;
|
||||
const len = Math.PI * R;
|
||||
return (
|
||||
<div className="si-gauge">
|
||||
<svg viewBox="0 0 140 78" className="si-gauge__svg" role="img" aria-label={`${label}: ${value} su 100`}>
|
||||
<path d="M 14 70 A 56 56 0 0 1 126 70" fill="none" stroke="#e4dfd7" strokeWidth="10" strokeLinecap="round" />
|
||||
<path
|
||||
d="M 14 70 A 56 56 0 0 1 126 70" fill="none" strokeWidth="10" strokeLinecap="round"
|
||||
stroke={value >= 70 ? '#b05a4e' : value >= 45 ? '#c08a3e' : '#6f8f6a'}
|
||||
strokeDasharray={`${len * ratio} ${len}`}
|
||||
/>
|
||||
</svg>
|
||||
<p className="si-gauge__value">{value}<span className="si-gauge__max">/100</span></p>
|
||||
<p className="si-gauge__label">{label} · {qualitativa(value)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientDetail({ client, measurements, alerts, notes }: Props) {
|
||||
const [tab, setTab] = useState('panoramica');
|
||||
const [periodo, setPeriodo] = useState('8');
|
||||
const [metriche, setMetriche] = useState<string[]>(METRICHE.map((m) => m.key));
|
||||
const [noteCliente, setNoteCliente] = useState(notes);
|
||||
const [nuovaNota, setNuovaNota] = useState('');
|
||||
const [messaggi, setMessaggi] = useState(MESSAGGI_INIZIALI);
|
||||
const [bozza, setBozza] = useState('');
|
||||
const [tag, setTag] = useState<string[]>(client.tags);
|
||||
const [nuovoTag, setNuovoTag] = useState('');
|
||||
const [promemoria, setPromemoria] = useState(true);
|
||||
const [reportSemestrale, setReportSemestrale] = useState(false);
|
||||
|
||||
const ultima = measurements[measurements.length - 1];
|
||||
const stress = ultima?.stress ?? client.stress;
|
||||
const recovery = ultima?.recovery ?? 0;
|
||||
const balance = ultima?.balance ?? 0;
|
||||
const energy = ultima?.energy ?? 0;
|
||||
|
||||
const datiGrafico = useMemo(
|
||||
() => (periodo === 'all' ? measurements : measurements.slice(-Number(periodo))),
|
||||
[measurements, periodo]
|
||||
);
|
||||
const serieAttive = METRICHE.filter((m) => metriche.includes(m.key));
|
||||
const ultimeTre = [...measurements].slice(-3).reverse();
|
||||
const storico = [...measurements].reverse();
|
||||
|
||||
const toggleMetrica = (key: string) =>
|
||||
setMetriche((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||||
|
||||
const aggiungiNota = () => {
|
||||
const testo = nuovaNota.trim();
|
||||
if (!testo) return;
|
||||
setNoteCliente((prev) => [{ clientId: client.id, text: testo, when: client.lastMeasurement }, ...prev]);
|
||||
setNuovaNota('');
|
||||
};
|
||||
|
||||
const invia = () => {
|
||||
const testo = bozza.trim();
|
||||
if (!testo) return;
|
||||
setMessaggi((prev) => [...prev, { from: 'professionista', text: testo, when: 'ora' }]);
|
||||
setBozza('');
|
||||
};
|
||||
|
||||
const aggiungiTag = () => {
|
||||
const t = nuovoTag.trim();
|
||||
if (!t || tag.includes(t)) return;
|
||||
setTag((prev) => [...prev, t]);
|
||||
setNuovoTag('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="si-stack">
|
||||
<div className="si-client-head">
|
||||
<span className="si-avatar si-avatar--lg">{client.initials}</span>
|
||||
<div>
|
||||
<h1>{client.name}</h1>
|
||||
<p className="si-muted si-small">
|
||||
{client.age} anni · {client.sex} · in carico dal {client.since}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="si-grid si-grid--kpi">
|
||||
{[
|
||||
{ label: 'Stress', value: stress, note: qualitativa(stress) },
|
||||
{ label: 'Recupero', value: recovery, note: qualitativa(recovery) },
|
||||
{ label: 'Misurazioni', value: measurements.length, note: 'totali' },
|
||||
{ label: 'In carico da', value: client.since, note: 'data iscrizione' },
|
||||
].map((k) => (
|
||||
<div className="si-card" key={k.label}>
|
||||
<p className="si-kpi__label">{k.label}</p>
|
||||
<p className="si-kpi__value">{k.value}</p>
|
||||
<p className="si-muted si-small">{k.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="si-tabs" role="tablist">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key} type="button" role="tab" className="si-tabs__btn"
|
||||
aria-selected={t.key === tab} onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'panoramica' && (
|
||||
<div className="si-stack">
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Stato attuale</h2></div>
|
||||
<div className="si-gauges">
|
||||
<Gauge value={stress} label="Stress" />
|
||||
<Gauge value={recovery} label="Recupero" />
|
||||
<Gauge value={balance} label="Equilibrio" />
|
||||
<Gauge value={energy} label="Energia" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<h2 className="si-card__title">Andamento clinico</h2>
|
||||
</div>
|
||||
<div className="si-chips">
|
||||
{PERIODI.map((p) => (
|
||||
<button key={p.key} type="button" className="si-chip" aria-pressed={periodo === p.key} onClick={() => setPeriodo(p.key)}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="si-chips">
|
||||
{METRICHE.map((m) => (
|
||||
<button key={m.key} type="button" className="si-chip" aria-pressed={metriche.includes(m.key)} onClick={() => toggleMetrica(m.key)}>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DataChart data={datiGrafico} xKey="date" series={serieAttive} />
|
||||
</section>
|
||||
|
||||
<div className="si-grid si-grid--halves">
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Alert attivi</h2></div>
|
||||
{alerts.length === 0 ? (
|
||||
<p className="si-muted si-small">Nessun alert attivo per questo cliente.</p>
|
||||
) : (
|
||||
alerts.map((a, i) => (
|
||||
<div className={`si-alert${a.severity === 'error' ? ' si-alert--error' : ''}`} key={i}>
|
||||
<span className="si-alert__mark" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="si-alert__text">{a.text}</p>
|
||||
<p className="si-alert__meta">{a.when}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Ultime 3 misurazioni</h2></div>
|
||||
<ul className="si-list si-list--rows">
|
||||
{ultimeTre.map((m, i) => (
|
||||
<li key={i}>
|
||||
<span>
|
||||
<strong>{m.date}</strong>
|
||||
<span className="si-muted"> · {m.type} · stress {m.stress}</span>
|
||||
</span>
|
||||
<span className={`si-badge si-badge--${livello(m.stress)}`}>{qualitativa(m.stress)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="si-insight">
|
||||
<p className="si-insight__title">Sintesi</p>
|
||||
<p>
|
||||
{stress >= 70
|
||||
? `${client.name} mostra un livello di stress elevato nelle ultime misurazioni, con recupero notturno ridotto. Consigliata una revisione del carico settimanale e un lavoro sulla respirazione.`
|
||||
: `${client.name} mantiene un equilibrio complessivamente stabile. Continuare il monitoraggio periodico e mantenere le abitudini di recupero attuali.`}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'misurazioni' && (
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Storico misurazioni</h2></div>
|
||||
<div className="si-scroll-x">
|
||||
<table className="si-table">
|
||||
<thead>
|
||||
<tr><th>Data</th><th>Tipo</th><th>Stress</th><th>Recupero</th><th>Equilibrio</th><th>Energia</th><th>Durata</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{storico.map((m, i) => (
|
||||
<tr key={i}>
|
||||
<td className="si-table__num">{m.date}</td>
|
||||
<td>{m.type}</td>
|
||||
<td className="si-table__num">{m.stress}</td>
|
||||
<td className="si-table__num">{m.recovery}</td>
|
||||
<td className="si-table__num">{m.balance}</td>
|
||||
<td className="si-table__num">{m.energy}</td>
|
||||
<td className="si-table__num">{m.durationMin} min</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'analytics' && (
|
||||
<div className="si-stack">
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Trend dei quattro parametri</h2></div>
|
||||
<DataChart data={measurements} xKey="date" series={METRICHE} />
|
||||
</section>
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Stress e recupero, ultime misurazioni</h2></div>
|
||||
<DataChart type="bar" data={ultimeTre} xKey="date" series={[METRICHE[0], METRICHE[1]]} />
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'report' && (
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<h2 className="si-card__title">Report generati</h2>
|
||||
<button type="button" className="si-btn">Genera report</button>
|
||||
</div>
|
||||
<ul className="si-list si-list--rows">
|
||||
{REPORT.map((r) => (
|
||||
<li key={r.date}>
|
||||
<span><span className="si-muted si-table__num">{r.date}</span> · {r.label}</span>
|
||||
<button type="button" className="si-btn">Scarica</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'note' && (
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Note cliente</h2></div>
|
||||
<div className="si-stack">
|
||||
<label className="si-field">
|
||||
Aggiungi nota
|
||||
<textarea
|
||||
rows={3} value={nuovaNota} onChange={(e) => setNuovaNota(e.target.value)}
|
||||
placeholder="Scrivi una nota clinica su questo cliente…"
|
||||
/>
|
||||
</label>
|
||||
<div><button type="button" className="si-btn si-btn--solid" onClick={aggiungiNota}>Aggiungi nota</button></div>
|
||||
{noteCliente.length === 0 ? (
|
||||
<p className="si-muted si-small">Nessuna nota registrata per questo cliente.</p>
|
||||
) : (
|
||||
<ul className="si-list">
|
||||
{noteCliente.map((n, i) => (
|
||||
<li className="si-note-card" key={i}>
|
||||
<p className="si-note__meta">{n.when}</p>
|
||||
<p>{n.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'messaggi' && (
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Messaggi</h2></div>
|
||||
<ul className="si-chat">
|
||||
{messaggi.map((m, i) => (
|
||||
<li key={i} className={`si-chat__row si-chat__row--${m.from}`}>
|
||||
<div className="si-chat__bubble">
|
||||
<p>{m.text}</p>
|
||||
<p className="si-chat__when">{m.when}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="si-chat__compose">
|
||||
<label className="si-field si-field--inline">
|
||||
<span className="si-visually-hidden">Messaggio</span>
|
||||
<input type="text" value={bozza} onChange={(e) => setBozza(e.target.value)} placeholder="Scrivi un messaggio…" />
|
||||
</label>
|
||||
<button type="button" className="si-btn si-btn--solid" onClick={invia}>Invia</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'impostazioni' && (
|
||||
<div className="si-stack">
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Tag cliente</h2></div>
|
||||
<div className="si-tag-list">
|
||||
{tag.map((t) => (
|
||||
<span className="si-tag si-tag--removable" key={t}>
|
||||
{t}
|
||||
<button type="button" aria-label={`Rimuovi tag ${t}`} onClick={() => setTag((prev) => prev.filter((x) => x !== t))}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="si-chat__compose">
|
||||
<label className="si-field si-field--inline">
|
||||
<span className="si-visually-hidden">Nuovo tag</span>
|
||||
<input type="text" value={nuovoTag} onChange={(e) => setNuovoTag(e.target.value)} placeholder="Nuovo tag…" />
|
||||
</label>
|
||||
<button type="button" className="si-btn" onClick={aggiungiTag}>Aggiungi</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Preferenze</h2></div>
|
||||
<ul className="si-list si-list--rows">
|
||||
<li>
|
||||
<span>Promemoria automatici di misurazione</span>
|
||||
<input type="checkbox" checked={promemoria} onChange={(e) => setPromemoria(e.target.checked)} />
|
||||
</li>
|
||||
<li>
|
||||
<span>Invio automatico del report semestrale</span>
|
||||
<input type="checkbox" checked={reportSemestrale} onChange={(e) => setReportSemestrale(e.target.checked)} />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
// Indicatore sintetico: valore grande, etichetta e variazione con verso.
|
||||
interface Props { label: string; value: string | number; delta?: string; trend?: 'up' | 'down' }
|
||||
const { label, value, delta, trend = 'up' } = Astro.props;
|
||||
---
|
||||
<div class="si-card">
|
||||
<p class="si-kpi__label">{label}</p>
|
||||
<p class="si-kpi__value">{value}</p>
|
||||
{delta && (
|
||||
<p class:list={['si-kpi__delta', trend === 'down' ? 'si-kpi__delta--down' : 'si-kpi__delta--up']}>
|
||||
{trend === 'down' ? '▼' : '▲'} {delta}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,204 +0,0 @@
|
||||
// Modulo Sport: tre viste (dashboard, elenco sessioni con filtri, schede atleta) in un'unica
|
||||
// isola, perché condividono lo stesso insieme di sessioni e i filtri agiscono su quello.
|
||||
import { useMemo, useState } from 'react';
|
||||
import DataChart from './charts/DataChart';
|
||||
import type { Athlete, Session } from '../../lib/stress-index/data';
|
||||
import { dataBreve, dataOra, durata, kpiSport, perSettimana, riepilogoAtleti } from '../../lib/stress-index/sport';
|
||||
|
||||
interface Props {
|
||||
sessions: Session[];
|
||||
athletes: Athlete[];
|
||||
/** Ultimo giorno coperto dai dati: tutte le finestre temporali partono da qui. */
|
||||
riferimento: string;
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'sessioni', label: 'Sessioni' },
|
||||
{ key: 'atleti', label: 'Atleti' },
|
||||
];
|
||||
|
||||
const PERIODI = [
|
||||
{ key: 'tutto', label: 'Tutto', giorni: 0 },
|
||||
{ key: '7', label: 'Ultimi 7 giorni', giorni: 7 },
|
||||
{ key: '30', label: 'Ultimi 30 giorni', giorni: 30 },
|
||||
{ key: '90', label: 'Ultimi 90 giorni', giorni: 90 },
|
||||
];
|
||||
|
||||
export default function SportView({ sessions, athletes, riferimento }: Props) {
|
||||
const [tab, setTab] = useState('dashboard');
|
||||
const [atleta, setAtleta] = useState('tutti');
|
||||
const [periodo, setPeriodo] = useState('tutto');
|
||||
const [sport, setSport] = useState('tutti');
|
||||
|
||||
const sports = useMemo(
|
||||
() => [...new Set(sessions.map((s) => s.sport).filter((s): s is string => Boolean(s)))],
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const filtrate = useMemo(() => {
|
||||
const giorni = PERIODI.find((p) => p.key === periodo)?.giorni ?? 0;
|
||||
const limite = new Date(`${riferimento}T00:00:00`);
|
||||
limite.setDate(limite.getDate() - giorni + 1);
|
||||
const dal = limite.toISOString().slice(0, 10);
|
||||
return sessions.filter((s) =>
|
||||
(atleta === 'tutti' || s.athleteId === atleta) &&
|
||||
(sport === 'tutti' || s.sport === sport) &&
|
||||
(!giorni || s.startedAt.slice(0, 10) >= dal)
|
||||
);
|
||||
}, [sessions, atleta, periodo, sport, riferimento]);
|
||||
|
||||
const kpi = useMemo(() => kpiSport(sessions, riferimento), [sessions, riferimento]);
|
||||
const settimane = useMemo(() => perSettimana(sessions, 12, riferimento), [sessions, riferimento]);
|
||||
const atletiRiepilogo = useMemo(() => riepilogoAtleti(sessions, athletes, riferimento), [sessions, athletes, riferimento]);
|
||||
const nome = (id: string) => athletes.find((a) => a.clientId === id)?.name ?? id;
|
||||
|
||||
return (
|
||||
<div className="si-stack">
|
||||
<div className="si-head">
|
||||
<h1>Modulo Sport <span className="si-badge si-badge--pro">Pro</span></h1>
|
||||
<p>Sessioni di allenamento, zone DFA Alpha1, carico e recupero degli atleti</p>
|
||||
</div>
|
||||
|
||||
<div className="si-tabs" role="tablist">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key} type="button" role="tab" className="si-tabs__btn" aria-selected={t.key === tab} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'dashboard' && (
|
||||
<div className="si-stack">
|
||||
<div className="si-grid si-grid--kpi">
|
||||
{kpi.map((k) => (
|
||||
<div className="si-card" key={k.label}>
|
||||
<p className="si-kpi__label">{k.label}</p>
|
||||
<p className="si-kpi__value">{k.value}</p>
|
||||
{k.note && <p className="si-muted si-small">{k.note}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-card__title">Sessioni per settimana</h2>
|
||||
<p className="si-muted si-small">Ultime 12 settimane</p>
|
||||
</div>
|
||||
</div>
|
||||
<DataChart type="bar" data={settimane} xKey="settimana" series={[{ key: 'sessioni', label: 'Sessioni', color: '#9c8b70' }]} />
|
||||
</section>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-card__head"><h2 className="si-card__title">Ultime sessioni</h2></div>
|
||||
<div className="si-scroll-x">
|
||||
<table className="si-table">
|
||||
<thead>
|
||||
<tr><th>Atleta</th><th>Data</th><th>Sport</th><th>Durata</th><th>TRIMP</th><th>HR medio</th><th>DFA A1</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.slice(0, 10).map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td><a href={`/piattaforme/stress-index/clienti/${s.athleteId}`}>{nome(s.athleteId)}</a></td>
|
||||
<td className="si-muted">{dataBreve(s.startedAt)}</td>
|
||||
<td>{s.sport ?? '—'}</td>
|
||||
<td className="si-table__num">{durata(s.durationSec)}</td>
|
||||
<td className="si-table__num">{s.trimp}</td>
|
||||
<td className="si-table__num">{s.hrAvg} bpm</td>
|
||||
<td className="si-table__num">{s.dfaA1 ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'sessioni' && (
|
||||
<div className="si-stack">
|
||||
<section className="si-card">
|
||||
<div className="si-filters">
|
||||
<label className="si-field">Atleta
|
||||
<select value={atleta} onChange={(e) => setAtleta(e.target.value)}>
|
||||
<option value="tutti">Tutti</option>
|
||||
{athletes.map((a) => <option key={a.clientId} value={a.clientId}>{a.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="si-field">Periodo
|
||||
<select value={periodo} onChange={(e) => setPeriodo(e.target.value)}>
|
||||
{PERIODI.map((p) => <option key={p.key} value={p.key}>{p.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="si-field">Sport
|
||||
<select value={sport} onChange={(e) => setSport(e.target.value)}>
|
||||
<option value="tutti">Tutti</option>
|
||||
{sports.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<p className="si-muted si-small si-filters__count">
|
||||
{filtrate.length} {filtrate.length === 1 ? 'sessione' : 'sessioni'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="si-card">
|
||||
<div className="si-scroll-x">
|
||||
<table className="si-table">
|
||||
<thead>
|
||||
<tr><th>Data e ora</th><th>Atleta</th><th>Sport</th><th>Durata</th><th>HR medio</th><th>HR max</th><th>TRIMP</th><th>DFA A1</th><th>RPE</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtrate.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="si-muted">{dataOra(s.startedAt)}</td>
|
||||
<td><a href={`/piattaforme/stress-index/clienti/${s.athleteId}`}>{nome(s.athleteId)}</a></td>
|
||||
<td>{s.sport ?? '—'}</td>
|
||||
<td className="si-table__num">{durata(s.durationSec)}</td>
|
||||
<td className="si-table__num">{s.hrAvg}</td>
|
||||
<td className="si-table__num">{s.hrMax}</td>
|
||||
<td className="si-table__num">{s.trimp}</td>
|
||||
<td className="si-table__num">{s.dfaA1 ?? '—'}</td>
|
||||
<td className="si-table__num">{s.rpe ? `${s.rpe}/10` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtrate.length === 0 && <p className="si-muted si-small si-empty">Nessuna sessione con questi filtri.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'atleti' && (
|
||||
<div className="si-grid si-grid--halves">
|
||||
{atletiRiepilogo.map((a) => (
|
||||
<section className="si-card si-athlete" key={a.clientId}>
|
||||
<div className="si-card__head">
|
||||
<div>
|
||||
<h2 className="si-athlete__name">{a.name}</h2>
|
||||
<p className="si-muted si-small">{a.sport ?? 'Sport non specificato'}</p>
|
||||
</div>
|
||||
<span className={`si-badge si-badge--${a.trend === 'in calo' ? 'bad' : a.trend === 'in crescita' ? 'ok' : 'warn'}`}>
|
||||
{a.trend}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="si-athlete__stats">
|
||||
<div><p className="si-athlete__num">{a.sessioni30}</p><p className="si-muted si-small">Sessioni 30gg</p></div>
|
||||
<div><p className="si-athlete__num">{a.trimp7}</p><p className="si-muted si-small">TRIMP 7gg</p></div>
|
||||
<div><p className="si-athlete__num">{a.ultimoTrimp}</p><p className="si-muted si-small">Ultimo TRIMP</p></div>
|
||||
</div>
|
||||
|
||||
<a className="si-athlete__foot" href={`/piattaforme/stress-index/clienti/${a.clientId}`}>
|
||||
<span>{a.ultimaSessione ? `Ultima sessione ${dataBreve(a.ultimaSessione)}` : 'Nessuna sessione registrata'}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
// Coppie etichetta/valore incolonnate, usate nei riquadri di riepilogo.
|
||||
interface Props { items: { label: string; value: string | number }[] }
|
||||
const { items } = Astro.props;
|
||||
---
|
||||
<dl class="si-list si-list--defs">
|
||||
{items.map((i) => (
|
||||
<div class="si-list__row">
|
||||
<dt class="si-muted">{i.label}</dt>
|
||||
<dd class="si-table__num">{i.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -1,56 +0,0 @@
|
||||
// Andamento medio dello studio, con selettore di periodo. L'aggregazione media stress e
|
||||
// recupero di tutti i clienti sugli ultimi N rilievi settimanali.
|
||||
import { useState } from 'react';
|
||||
import DataChart from './DataChart';
|
||||
import type { Measurement } from '../../../lib/stress-index/data';
|
||||
|
||||
const PERIODS = [
|
||||
{ key: '30g', label: '30 giorni', points: 5 },
|
||||
{ key: '90g', label: '90 giorni', points: 13 },
|
||||
{ key: '6m', label: '6 mesi', points: 20 },
|
||||
{ key: 'tutto', label: 'Tutto', points: 20 },
|
||||
] as const;
|
||||
|
||||
interface Props { measurementsByClient: Record<string, Measurement[]> }
|
||||
|
||||
function aggregate(byClient: Record<string, Measurement[]>, points: number) {
|
||||
const out: { date: string; stress: number; recovery: number }[] = [];
|
||||
for (let fromEnd = points; fromEnd >= 1; fromEnd--) {
|
||||
let stress = 0, recovery = 0, count = 0, date = '';
|
||||
for (const series of Object.values(byClient)) {
|
||||
const point = series[series.length - fromEnd];
|
||||
if (!point) continue;
|
||||
stress += point.stress;
|
||||
recovery += point.recovery;
|
||||
count += 1;
|
||||
date = point.date;
|
||||
}
|
||||
if (count) out.push({ date, stress: Math.round(stress / count), recovery: Math.round(recovery / count) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function AndamentoChart({ measurementsByClient }: Props) {
|
||||
const [period, setPeriod] = useState<string>('90g');
|
||||
const active = PERIODS.find((p) => p.key === period) ?? PERIODS[1];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="si-chips">
|
||||
{PERIODS.map((p) => (
|
||||
<button key={p.key} type="button" className="si-chip" aria-pressed={p.key === period} onClick={() => setPeriod(p.key)}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DataChart
|
||||
data={aggregate(measurementsByClient, active.points)}
|
||||
xKey="date"
|
||||
series={[
|
||||
{ key: 'stress', label: 'Stress medio' },
|
||||
{ key: 'recovery', label: 'Recupero medio' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Grafico generico della piattaforma (isola React: recharts ha bisogno del DOM).
|
||||
// I colori sono quelli del sito, non quelli del prototipo originale.
|
||||
import {
|
||||
ResponsiveContainer, LineChart, BarChart, Line, Bar, CartesianGrid, XAxis, YAxis, Tooltip, Legend,
|
||||
} from 'recharts';
|
||||
|
||||
export interface ChartSeries { key: string; label: string; color?: string }
|
||||
|
||||
interface Props {
|
||||
type?: 'line' | 'bar';
|
||||
data: Record<string, string | number>[];
|
||||
xKey: string;
|
||||
series: ChartSeries[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const COLORS = ['#9c8b70', '#3a2929', '#6f8f6a', '#c08a3e', '#b05a4e'];
|
||||
const AXIS = '#8a8a8a';
|
||||
|
||||
export default function DataChart({ type = 'line', data, xKey, series, height = 260 }: Props) {
|
||||
if (!data.length) return <p className="si-chart__empty">Nessun dato nel periodo selezionato.</p>;
|
||||
const Chart = type === 'bar' ? BarChart : LineChart;
|
||||
|
||||
return (
|
||||
<div className="si-chart" style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<Chart data={data} margin={{ top: 6, right: 8, bottom: 0, left: -18 }}>
|
||||
<CartesianGrid stroke="#e4dfd7" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey={xKey} stroke={AXIS} fontSize={11} tickLine={false} />
|
||||
<YAxis stroke={AXIS} fontSize={11} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ border: '1px solid #e4dfd7', borderRadius: 0, fontSize: 12 }}
|
||||
labelStyle={{ color: '#2b2b2b', fontWeight: 600 }}
|
||||
/>
|
||||
{series.length > 1 && <Legend iconType="plainline" wrapperStyle={{ fontSize: 12 }} />}
|
||||
{series.map((s, i) =>
|
||||
type === 'bar' ? (
|
||||
// Un dato che porta con sé un campo `fill` colora la propria barra: è così che
|
||||
// l'istogramma delle fasce di score usa un colore per fascia.
|
||||
<Bar key={s.key} dataKey={s.key} name={s.label} fill={s.color ?? COLORS[i % COLORS.length]} />
|
||||
) : (
|
||||
<Line
|
||||
key={s.key} type="monotone" dataKey={s.key} name={s.label} dot={false} strokeWidth={2.2}
|
||||
stroke={s.color ?? COLORS[i % COLORS.length]}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Chart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// Piattaforme riservate: sezioni applicative accessibili solo ai ruoli `piattaforme` e `admin`.
|
||||
// Elenco unico usato dal menu dell'header e dalla pagina indice /piattaforme, così aggiungerne
|
||||
// una in futuro è una riga sola.
|
||||
export interface Piattaforma {
|
||||
slug: string;
|
||||
label: string;
|
||||
href: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const piattaforme: Piattaforma[] = [
|
||||
{
|
||||
slug: 'campus',
|
||||
label: 'Biohacking Campus',
|
||||
href: '/campus',
|
||||
description: 'Le guide di studio del corso: aree, capitoli e lezioni con le slide.',
|
||||
},
|
||||
{
|
||||
slug: 'stress-index',
|
||||
label: 'Stress Index',
|
||||
href: '/piattaforme/stress-index',
|
||||
description: 'Monitoraggio HRV di stress e recupero: clienti, andamenti e alert.',
|
||||
},
|
||||
];
|
||||
@@ -1,131 +0,0 @@
|
||||
---
|
||||
// Layout della sezione riservata Campus. Usa i token e i font del sito, ma
|
||||
// non ne monta Header e Footer: la navigazione è tutta interna, con un solo
|
||||
// richiamo alla home.
|
||||
import '@fontsource/montserrat/400.css';
|
||||
import '@fontsource/montserrat/500.css';
|
||||
import '@fontsource/montserrat/600.css';
|
||||
import '@fontsource/open-sans/400.css';
|
||||
import '@fontsource/open-sans/600.css';
|
||||
import '../styles/global.css';
|
||||
import type { CampusNav } from '../lib/campus';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
nav: CampusNav;
|
||||
currentArea?: string;
|
||||
currentLesson?: string;
|
||||
}
|
||||
const { title, nav, currentArea, currentLesson } = Astro.props;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} — Biohacking InsanityLab</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<body class="cp">
|
||||
<input type="checkbox" id="cp-menu" class="cp-menu-toggle" hidden />
|
||||
<label for="cp-menu" class="cp-menu-btn" aria-label="Apri il menu">☰ Indice</label>
|
||||
|
||||
<aside class="cp-side">
|
||||
<a class="cp-brand" href="/campus">
|
||||
<span class="eyebrow">InsanityLab</span>
|
||||
Biohacking
|
||||
</a>
|
||||
<a class="cp-home" href="/piattaforme">Piattaforme</a>
|
||||
|
||||
<nav class="cp-nav">
|
||||
{nav.areas.map((area) => (
|
||||
<div class="cp-area" data-on={area.slug === currentArea ? '' : null}>
|
||||
<a class="cp-area__link" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>{area.title}</a>
|
||||
{area.slug === currentArea && (
|
||||
<ul class="cp-chapters">
|
||||
{area.chapters.map((ch) => (
|
||||
<li>
|
||||
<span class="cp-ch" style={`--c:${ch.color}`}>{ch.num}. {ch.title}</span>
|
||||
<ul>
|
||||
{ch.lessons.map((l) => (
|
||||
<li class={l.slug === currentLesson ? 'on' : undefined}>
|
||||
<a href={`/campus/${area.slug}/${l.slug}`}>
|
||||
<span class="cp-label">{l.label}</span> {l.titleShort}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="cp-main"><slot /></main>
|
||||
|
||||
<style is:global>
|
||||
body.cp { display: flex; align-items: flex-start; background: var(--c-bg); }
|
||||
|
||||
/* --- sidebar --- */
|
||||
.cp-side {
|
||||
width: 300px; flex: 0 0 300px; height: 100vh; position: sticky; top: 0; overflow-y: auto;
|
||||
background: var(--c-dark); color: #cfc6bd; padding: 28px 22px 50px;
|
||||
}
|
||||
.cp-brand { display: block; font-family: var(--font-heading); font-size: 1.5rem; font-weight: 600;
|
||||
letter-spacing: .12em; text-transform: uppercase; color: #fff; text-decoration: none; line-height: 1.1; }
|
||||
.cp-brand .eyebrow { display: block; color: var(--c-accent); margin-bottom: 4px; }
|
||||
.cp-home { display: inline-block; margin: 18px 0 24px; font-family: var(--font-heading); font-size: .7rem;
|
||||
font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--c-accent);
|
||||
text-decoration: none; border-bottom: 1px solid transparent; }
|
||||
.cp-home:hover { border-bottom-color: var(--c-accent); }
|
||||
|
||||
.cp-nav { border-top: 1px solid rgba(255,255,255,.12); padding-top: 18px; }
|
||||
.cp-area + .cp-area { margin-top: 14px; }
|
||||
.cp-area__link { display: block; font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
|
||||
letter-spacing: .12em; text-transform: uppercase; color: #fff; text-decoration: none;
|
||||
padding: 8px 10px; border-left: 3px solid var(--c); }
|
||||
.cp-area__link:hover, .cp-area[data-on] .cp-area__link { background: rgba(255,255,255,.07); }
|
||||
|
||||
.cp-chapters { list-style: none; margin: 8px 0 0; padding: 0; }
|
||||
.cp-chapters > li + li { margin-top: 12px; }
|
||||
.cp-ch { display: block; font-family: var(--font-heading); font-size: .72rem; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--c); padding-left: 12px; }
|
||||
.cp-chapters ul { list-style: none; margin: 4px 0 0; padding: 0 0 0 12px; }
|
||||
.cp-chapters ul a { display: block; text-decoration: none; color: #b6ada3; font-size: .85rem;
|
||||
line-height: 1.35; padding: 4px 8px; }
|
||||
.cp-chapters ul a:hover { color: #fff; background: rgba(255,255,255,.06); }
|
||||
.cp-chapters ul li.on a { color: #fff; background: rgba(184,169,140,.22); }
|
||||
.cp-label { font-family: var(--font-heading); font-size: .68rem; letter-spacing: .06em; color: var(--c-accent); }
|
||||
|
||||
/* --- contenuto --- */
|
||||
.cp-main { flex: 1; min-width: 0; max-width: 920px; margin: 0 auto; padding: 56px 44px 100px; }
|
||||
.cp-main h1 { font-size: clamp(1.9rem, 3.4vw, 2.8rem); text-transform: none; letter-spacing: .02em; }
|
||||
.cp-main h2 { font-size: clamp(1.3rem, 2.2vw, 1.7rem); margin-top: 2em; padding-bottom: .25em;
|
||||
border-bottom: 1px solid #e8e4de; text-transform: none; letter-spacing: .02em; }
|
||||
.cp-main h3 { font-size: 1.12rem; margin-top: 1.7em; color: var(--c-accent-dark); }
|
||||
.cp-main img { margin: 22px 0; border: 1px solid #e8e4de; }
|
||||
.cp-main table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: .92rem; }
|
||||
.cp-main th, .cp-main td { border: 1px solid #e8e4de; padding: 8px 12px; text-align: left; vertical-align: top; }
|
||||
.cp-main th { background: var(--c-bg-alt); font-family: var(--font-heading); font-weight: 600; }
|
||||
.cp-main blockquote { margin: 20px 0; padding: 4px 20px; border-left: 3px solid var(--c-accent); color: #6a6a6a; }
|
||||
.cp-main code { background: var(--c-bg-alt); padding: 1px 5px; font-size: .9em; }
|
||||
.cp-main li { margin-bottom: .35em; }
|
||||
|
||||
/* --- menu mobile (solo CSS) --- */
|
||||
.cp-menu-btn { display: none; }
|
||||
@media (max-width: 900px) {
|
||||
body.cp { display: block; }
|
||||
.cp-menu-btn { display: block; position: sticky; top: 0; z-index: 20; background: var(--c-dark); color: #fff;
|
||||
font-family: var(--font-heading); font-size: .75rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; padding: 14px 20px; cursor: pointer; }
|
||||
.cp-side { display: none; width: auto; flex: none; height: auto; position: static; }
|
||||
.cp-menu-toggle:checked ~ .cp-side { display: block; }
|
||||
.cp-main { padding: 32px 20px 70px; }
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
// Layout della piattaforma Longevity. Come Stress Index: token, font e palette del sito,
|
||||
// senza Header e Footer pubblici — l'area è riservata e non va mai indicizzata.
|
||||
// A differenza di Stress Index (barra laterale con sette voci, giusta per chi ci lavora
|
||||
// dentro) qui i pubblici sono due, e uno non è staff: il cliente entra tre o quattro
|
||||
// volte l'anno a leggere il proprio referto, non gli serve l'arredamento di uno
|
||||
// strumento di lavoro. Il layout prevede due forme:
|
||||
// - "referto": una colonna che scorre, senza navigazione (questo task)
|
||||
// - "gestionale": barra laterale come Stress Index (arriva col piano del gestionale)
|
||||
import '@fontsource/montserrat/400.css';
|
||||
import '@fontsource/montserrat/500.css';
|
||||
import '@fontsource/montserrat/600.css';
|
||||
import '@fontsource/open-sans/400.css';
|
||||
import '@fontsource/open-sans/600.css';
|
||||
import '../styles/global.css';
|
||||
import '../styles/longevity.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
/** Voci di percorso mostrate nell'intestazione, dopo "Longevity". */
|
||||
crumbs?: string[];
|
||||
/** "referto" (colonna centrata, senza navigazione) o "gestionale" (barra laterale, non ancora implementata). */
|
||||
variante?: 'referto' | 'gestionale';
|
||||
}
|
||||
const { title, crumbs = [], variante = 'referto' } = Astro.props;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} — Longevity InsanityLab</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<body class="lg" data-variante={variante}>
|
||||
<div class="lg-shell">
|
||||
<header class="lg-top">
|
||||
<p class="lg-top__crumbs">
|
||||
<strong>Longevity</strong>
|
||||
{crumbs.map((c) => <span> / {c}</span>)}
|
||||
</p>
|
||||
</header>
|
||||
<main class="lg-main"><slot /></main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
// Layout della piattaforma Stress Index. Come il Campus: token, font e palette del sito,
|
||||
// senza Header e Footer pubblici — la navigazione è tutta interna alla piattaforma.
|
||||
import '@fontsource/montserrat/400.css';
|
||||
import '@fontsource/montserrat/500.css';
|
||||
import '@fontsource/montserrat/600.css';
|
||||
import '@fontsource/open-sans/400.css';
|
||||
import '@fontsource/open-sans/600.css';
|
||||
import '../styles/global.css';
|
||||
import '../styles/stress-index.css';
|
||||
import { professional } from '../lib/stress-index/data';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
/** Voci di percorso mostrate nella topbar, dopo "Stress Index". */
|
||||
crumbs?: string[];
|
||||
}
|
||||
const { title, crumbs = [] } = Astro.props;
|
||||
const path = Astro.url.pathname;
|
||||
|
||||
const BASE = '/piattaforme/stress-index';
|
||||
const NAV = [
|
||||
{ href: BASE, label: 'Oggi' },
|
||||
{ href: `${BASE}/clienti`, label: 'Clienti' },
|
||||
{ href: `${BASE}/analytics`, label: 'Analytics' },
|
||||
{ href: `${BASE}/sport`, label: 'Sport', badge: 'Pro' },
|
||||
{ href: `${BASE}/sport/team-live`, label: 'Team live' },
|
||||
{ href: `${BASE}/impostazioni`, label: 'Impostazioni' },
|
||||
{ href: `${BASE}/organizzazione`, label: 'Organizzazione' },
|
||||
];
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} — Stress Index InsanityLab</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<body class="si">
|
||||
<input type="checkbox" id="si-menu" class="si-menu-toggle" hidden />
|
||||
<label for="si-menu" class="si-menu-btn" aria-label="Apri il menu">☰ Menu</label>
|
||||
|
||||
<aside class="si-side">
|
||||
<a class="si-brand" href={BASE}>
|
||||
<span class="eyebrow">InsanityLab</span>
|
||||
Stress Index
|
||||
</a>
|
||||
<a class="si-home" href="/piattaforme">Piattaforme</a>
|
||||
|
||||
<nav class="si-nav">
|
||||
{NAV.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={['si-nav__link', { 'is-active': path === item.href }]}
|
||||
>
|
||||
{item.label}
|
||||
{item.badge && <span class="si-nav__badge">{item.badge}</span>}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="si-body">
|
||||
<header class="si-top">
|
||||
<p class="si-top__crumbs">
|
||||
<strong>Stress Index</strong>
|
||||
{crumbs.map((c) => <span> / {c}</span>)}
|
||||
</p>
|
||||
<p class="si-top__studio">{professional.studio}</p>
|
||||
</header>
|
||||
<main class="si-main"><slot /></main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+13
-22
@@ -10,7 +10,10 @@ const SESSION_DAYS = 7;
|
||||
// indipendenti - questo tipo, isRole, la tendina di admin/users.astro e create-user.mjs -
|
||||
// ed è già andato fuori sincrono due volte in questo stesso ramo (create-user.mjs resta
|
||||
// duplicato: è JavaScript, non può importare da qui, vedi il commento lì).
|
||||
export const ROLES = ['user', 'superuser', 'piattaforme', 'admin', 'cliente', 'trainer'] as const;
|
||||
// ⚠️ Solo i ruoli redazionali: `piattaforme`, `cliente` e `trainer` sono passati
|
||||
// all'app delle aree riservate (apps/platforms) insieme alle loro pagine. Un utente
|
||||
// del sito non è un utente di quella, e viceversa: due anagrafiche, due database.
|
||||
export const ROLES = ['user', 'superuser', 'admin'] as const;
|
||||
export type Role = typeof ROLES[number];
|
||||
|
||||
export function hashPassword(plain: string): string {
|
||||
@@ -65,15 +68,6 @@ const RULES: [RegExp, Role[]][] = [
|
||||
[/^\/api\/admin\/users(\/|$)/, ['admin']],
|
||||
[/^\/admin\/content(\/|$)/, ['admin', 'superuser']],
|
||||
[/^\/api\/admin\/content(\/|$)/, ['admin', 'superuser']],
|
||||
// Le piattaforme riservate: il Campus Biohacking (contenuti) e l'indice/dashboard
|
||||
// sotto /piattaforme. Stesso ruolo per entrambe.
|
||||
[/^\/campus(\/|$)/, ['admin', 'piattaforme']],
|
||||
[/^\/piattaforme(\/|$)/, ['admin', 'piattaforme']],
|
||||
// Longevity: il cliente vede solo il proprio spazio, il gestionale è del trainer.
|
||||
[/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
|
||||
[/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
|
||||
[/^\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']],
|
||||
[/^\/api\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']],
|
||||
];
|
||||
|
||||
export function canAccessAdminPath(role: string, pathname: string): boolean {
|
||||
@@ -81,10 +75,11 @@ export function canAccessAdminPath(role: string, pathname: string): boolean {
|
||||
for (const [re, roles] of RULES) {
|
||||
if (re.test(pathname)) return roles.includes(role as Role);
|
||||
}
|
||||
// piattaforme vede solo le sue sezioni (coperte da RULES) e il logout
|
||||
if (role === 'piattaforme') return pathname === '/admin/logout';
|
||||
// cliente e trainer non sono utenti del sito: fuori dalle rotte elencate non passano.
|
||||
if (role === 'cliente' || role === 'trainer') return pathname === '/admin/logout';
|
||||
// ⚠️ Un ruolo che qui non esiste più (piattaforme, cliente, trainer: rimasti nel database
|
||||
// di produzione da prima della separazione) non deve cadere nel default permissivo qui
|
||||
// sotto, che è pensato per gli utenti redazionali. Chi non è un ruolo di questo sito non
|
||||
// passa: la sua casa è apps.insanitylab.it.
|
||||
if (!isRole(role)) return pathname === '/admin/logout';
|
||||
return true; // blog, upload, logout, showtags: tutti i loggati
|
||||
}
|
||||
|
||||
@@ -130,19 +125,15 @@ export function isRole(v: unknown): v is Role {
|
||||
|
||||
export function landingFor(role: string): string {
|
||||
if (role === 'superuser') return '/admin/content';
|
||||
if (role === 'piattaforme') return '/piattaforme';
|
||||
if (role === 'cliente') return '/longevity/io';
|
||||
if (role === 'trainer') return '/longevity/gestionale';
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
export function isProtectedPath(pathname: string): boolean {
|
||||
// ⚠️ Restano solo il pannello e le sue API: /campus, /piattaforme e /longevity sono
|
||||
// passate ad apps.insanitylab.it il 03/09/2026. Se un giorno ricompaiono qui degli
|
||||
// URL con quei prefissi, non sono protetti da niente — non esistono proprio.
|
||||
return (
|
||||
(pathname.startsWith('/admin') && pathname !== '/admin/login') ||
|
||||
pathname.startsWith('/api/admin') ||
|
||||
pathname.startsWith('/campus') ||
|
||||
pathname.startsWith('/piattaforme') ||
|
||||
pathname.startsWith('/longevity') ||
|
||||
pathname.startsWith('/api/longevity')
|
||||
pathname.startsWith('/api/admin')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
// Accesso ai contenuti della sezione Campus, generati da `npm run sync-campus`
|
||||
// in campus-content/. I file sono letti a runtime (non a build time) e tenuti
|
||||
// in cache in memoria: cambiano solo con un nuovo deploy.
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, normalize, resolve } from 'node:path';
|
||||
|
||||
export interface CampusLink { slug: string; area: string; title: string }
|
||||
|
||||
export interface CampusLesson {
|
||||
slug: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
titleFull: string;
|
||||
titleShort: string;
|
||||
relatore: string;
|
||||
concetti: string[];
|
||||
chapter: number;
|
||||
area: string;
|
||||
prev: CampusLink | null;
|
||||
next: CampusLink | null;
|
||||
}
|
||||
|
||||
export interface CampusChapter {
|
||||
num: number;
|
||||
title: string;
|
||||
sub: string;
|
||||
color: string;
|
||||
lessons: CampusLesson[];
|
||||
}
|
||||
|
||||
export interface CampusArea {
|
||||
slug: string;
|
||||
title: string;
|
||||
sub: string;
|
||||
color: string;
|
||||
chapters: CampusChapter[];
|
||||
}
|
||||
|
||||
export interface CampusNav { areas: CampusArea[]; count: number }
|
||||
|
||||
const CONTENT_DIR = resolve(process.env.CAMPUS_DIR ?? './campus-content');
|
||||
|
||||
let navCache: CampusNav | null = null;
|
||||
|
||||
/** Struttura della sezione, o null se i contenuti non sono stati generati. */
|
||||
export function getNav(): CampusNav | null {
|
||||
if (navCache) return navCache;
|
||||
const file = join(CONTENT_DIR, 'nav.json');
|
||||
if (!existsSync(file)) {
|
||||
console.warn(`Campus: contenuti assenti in ${CONTENT_DIR}. Eseguire "npm run sync-campus".`);
|
||||
return null;
|
||||
}
|
||||
navCache = JSON.parse(readFileSync(file, 'utf8')) as CampusNav;
|
||||
return navCache;
|
||||
}
|
||||
|
||||
export function findArea(nav: CampusNav, slug: string): CampusArea | null {
|
||||
return nav.areas.find((a) => a.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export function findLesson(nav: CampusNav, slug: string): CampusLesson | null {
|
||||
for (const area of nav.areas)
|
||||
for (const ch of area.chapters) {
|
||||
const l = ch.lessons.find((x) => x.slug === slug);
|
||||
if (l) return l;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function chapterOf(nav: CampusNav, lesson: CampusLesson): CampusChapter | null {
|
||||
for (const area of nav.areas) {
|
||||
const ch = area.chapters.find((c) => c.num === lesson.chapter);
|
||||
if (ch) return ch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Corpo HTML di una guida; null se il frammento non esiste. */
|
||||
export function readLessonHtml(slug: string): string | null {
|
||||
const file = join(CONTENT_DIR, 'pages', `${slug}.html`);
|
||||
if (!existsSync(file)) return null;
|
||||
return readFileSync(file, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Percorso assoluto di un asset dentro campus-content/assets, oppure null se
|
||||
* il path esce dalla cartella (traversal) o il file non esiste.
|
||||
*/
|
||||
export function resolveAsset(relPath: string): string | null {
|
||||
const base = join(CONTENT_DIR, 'assets');
|
||||
const full = resolve(base, normalize(relPath));
|
||||
if (full !== base && !full.startsWith(base + '/')) return null;
|
||||
return existsSync(full) ? full : null;
|
||||
}
|
||||
@@ -238,10 +238,10 @@ export function createDb(path?: string): Database.Database {
|
||||
const updAbout = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?');
|
||||
const updAboutTx = db.transaction(() => { for (const [tag, frag, newV] of ABOUT_02) updAbout.run(newV, tag, frag); });
|
||||
updAboutTx();
|
||||
// Il ruolo `campus` diventa `piattaforme`: non dà più accesso alla sola sezione Biohacking
|
||||
// ma a tutte le piattaforme riservate (Campus, Stress Index). Rinomina una tantum delle
|
||||
// righe esistenti; il vincolo sui valori ammessi vive in auth.ts (isRole), non nello schema.
|
||||
db.prepare("UPDATE users SET role = 'piattaforme' WHERE role = 'campus'").run();
|
||||
// ⚠️ Qui c'era la rinomina una tantum del ruolo `campus` in `piattaforme`. Tolta il
|
||||
// 03/09/2026 con la separazione: quel ruolo non esiste più in questo sito, e la
|
||||
// migrazione avrebbe scritto in `users` un valore che nessuna regola di lib/auth
|
||||
// riconosce. Gli utenti delle aree riservate vivono in apps/platforms, col loro database.
|
||||
|
||||
// Rimossa la variante di test /promo-eventi2 e la sua voce di menu (era la settima,
|
||||
// visibile ai soli admin): i tag che la servivano restano orfani nei DB esistenti.
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
//
|
||||
// QUESTO è l'unico modulo autorizzato ad aprire insieme identity e longevity.
|
||||
// La separazione dei due database è la garanzia che chi legge i dati clinici non
|
||||
// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione
|
||||
// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica.
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createIdentityDb } from './db';
|
||||
|
||||
// Connessione singola a identity, privata di questo modulo: non è esportata, quindi non
|
||||
// è raggiungibile da nessun'altra parte del codice, nemmeno con un re-export su più file
|
||||
// (un controllo testuale file-per-file non lo vedrebbe, ma qui il problema non si pone:
|
||||
// non esiste un nome da importare). QUESTO è il modo in cui il vincolo si rende vero,
|
||||
// non solo sorvegliato.
|
||||
let identitySingleton: Database.Database | null = null;
|
||||
function getIdentityDb(): Database.Database {
|
||||
if (!identitySingleton) identitySingleton = createIdentityDb();
|
||||
return identitySingleton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in
|
||||
* longevity.soggetti. Dedurlo da una sola metà è il bug che permette di riassegnare il
|
||||
* codice di un cliente cancellato dall'anagrafica (le sue misure restano in longevity)
|
||||
* a una persona nuova: qui non torna mai disponibile.
|
||||
*/
|
||||
function prossimoCodice(identity: Database.Database, longevity: Database.Database): string {
|
||||
const numero = (code: string) => Number(code.slice(4));
|
||||
const ultimoIdentity = identity.prepare(
|
||||
`SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1`
|
||||
).get() as { client_code: string } | undefined;
|
||||
const ultimoLongevity = longevity.prepare(
|
||||
`SELECT client_code FROM soggetti ORDER BY client_code DESC LIMIT 1`
|
||||
).get() as { client_code: string } | undefined;
|
||||
|
||||
const max = Math.max(
|
||||
ultimoIdentity ? numero(ultimoIdentity.client_code) : 0,
|
||||
ultimoLongevity ? numero(ultimoLongevity.client_code) : 0,
|
||||
);
|
||||
return `ISL-${String(max + 1).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
export function creaCliente(
|
||||
identity: Database.Database,
|
||||
longevity: Database.Database,
|
||||
dati: {
|
||||
nome: string; cognome: string; sesso: 'M' | 'F';
|
||||
data_nascita?: string; email?: string; telefono?: string; user_id?: number;
|
||||
}
|
||||
): string {
|
||||
const code = prossimoCodice(identity, longevity);
|
||||
|
||||
identity.prepare(
|
||||
`INSERT INTO clienti (client_code, user_id, nome, cognome, data_nascita, email, telefono)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(code, dati.user_id ?? null, dati.nome, dati.cognome,
|
||||
dati.data_nascita ?? null, dati.email ?? null, dati.telefono ?? null);
|
||||
|
||||
try {
|
||||
longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso);
|
||||
} catch (err) {
|
||||
// Compensazione: due database distinti, quindi nessuna transazione unica possibile.
|
||||
// Se longevity rifiuta il cliente non deve restarne uno orfano in identity, senza
|
||||
// corrispettivo nell'altro database.
|
||||
identity.prepare(`DELETE FROM clienti WHERE client_code = ?`).run(code);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
export function codicePerUtente(identity: Database.Database, userId: number): string | null {
|
||||
const r = identity.prepare(`SELECT client_code FROM clienti WHERE user_id = ?`).get(userId) as
|
||||
{ client_code: string } | undefined;
|
||||
return r?.client_code ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Il codice cliente a partire dall'utente di sessione del sito: apre lei stessa la
|
||||
* connessione a identity (getIdentityDb), perché QUESTO è il modulo autorizzato a farlo.
|
||||
* Un endpoint che deve solo sapere "chi è" chiama questa funzione e non ha mai bisogno
|
||||
* di aprire identity da sé — riceve una stringa, mai una connessione che potrebbe
|
||||
* incrociarsi con quella di longevity nello stesso file.
|
||||
*/
|
||||
export function codicePerUtenteLoggato(userId: number): string | null {
|
||||
return codicePerUtente(getIdentityDb(), userId);
|
||||
}
|
||||
|
||||
/** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
|
||||
export function etaAllaData(dataNascita: string, alla: string): number {
|
||||
const n = new Date(dataNascita);
|
||||
const d = new Date(alla);
|
||||
let eta = d.getFullYear() - n.getFullYear();
|
||||
const m = d.getMonth() - n.getMonth();
|
||||
if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--;
|
||||
return eta;
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
/**
|
||||
* Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore.
|
||||
* Vale a ogni livello E dentro il questionario: soglia unica, confermata da Nicola il
|
||||
* 22/08 — due criteri diversi sarebbero due cose da spiegare e da ricordare.
|
||||
*/
|
||||
export const COPERTURA_MINIMA = 0.4;
|
||||
|
||||
const SCHEMA_LONGEVITY = `
|
||||
CREATE TABLE IF NOT EXISTS soggetti (
|
||||
client_code TEXT PRIMARY KEY,
|
||||
sesso TEXT NOT NULL CHECK (sesso IN ('M','F')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS registro_test (
|
||||
test_id TEXT PRIMARY KEY,
|
||||
etichetta TEXT NOT NULL,
|
||||
unita TEXT,
|
||||
tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')),
|
||||
curva TEXT,
|
||||
params TEXT,
|
||||
asse TEXT,
|
||||
sotto_dominio TEXT,
|
||||
range_min REAL,
|
||||
range_max REAL,
|
||||
attivo_da TEXT NOT NULL,
|
||||
attivo_a TEXT,
|
||||
note TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessioni (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
|
||||
data TEXT NOT NULL,
|
||||
tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')),
|
||||
eta_alla_data INTEGER,
|
||||
operatore TEXT,
|
||||
note TEXT,
|
||||
quest_version TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessioni_cliente ON sessioni (client_code, data DESC);
|
||||
CREATE TABLE IF NOT EXISTS misure (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE,
|
||||
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
|
||||
test_id TEXT NOT NULL REFERENCES registro_test(test_id),
|
||||
valore_num REAL,
|
||||
valore_txt TEXT,
|
||||
unita TEXT,
|
||||
fonte TEXT NOT NULL CHECK (fonte IN
|
||||
('manuale','questionario','wellness_tower','vald','calibre','stress_index')),
|
||||
fuori_range INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_misure_sessione ON misure (sessione_id);
|
||||
CREATE TABLE IF NOT EXISTS pesi (
|
||||
model_version TEXT NOT NULL,
|
||||
livello TEXT NOT NULL CHECK (livello IN ('asse','macro','fitness_age')),
|
||||
contenitore TEXT NOT NULL,
|
||||
elemento TEXT NOT NULL,
|
||||
peso REAL NOT NULL,
|
||||
PRIMARY KEY (model_version, livello, contenitore, elemento)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS profilo_note (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
|
||||
sessione_id INTEGER REFERENCES sessioni(id),
|
||||
campo_id TEXT NOT NULL,
|
||||
testo TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS score (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
|
||||
sessione_id INTEGER NOT NULL REFERENCES sessioni(id),
|
||||
tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')),
|
||||
elemento TEXT NOT NULL,
|
||||
valore REAL,
|
||||
copertura REAL NOT NULL,
|
||||
stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')),
|
||||
quest_version TEXT,
|
||||
model_version TEXT NOT NULL,
|
||||
calcolato_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
-- Il tipo Punteggio protegge il codice ma non la tabella: l'interfaccia leggera'
|
||||
-- 'score' direttamente, dove quel tipo non c'e' piu'. Questo vincolo lo riscrive
|
||||
-- a livello di dato: o lo stato e' 'ok' e il valore c'e', o e' 'insufficiente' e
|
||||
-- il valore e' NULL, mai un numero scritto sotto uno stato che lo nega.
|
||||
-- ATTENZIONE al limite, che non e' quello che sembra: lo schema qui sopra gira con
|
||||
-- CREATE TABLE IF NOT EXISTS, che su un file gia' esistente e' un'operazione a vuoto.
|
||||
-- Quindi un longevity.db creato prima di questa modifica NON ha questo vincolo, e
|
||||
-- continuera' a non averlo finche' qualcuno non lo migra a mano.
|
||||
-- (Non e' un limite di SQLite: la versione imbarcata da better-sqlite3 e' la 3.53,
|
||||
-- che ALTER TABLE ... ADD CONSTRAINT lo supporta. E' che qui una migrazione non c'e'.
|
||||
-- Verificato il 22/08: la CLI di sistema e' la 3.37 e non lo supporta, quindi provarlo
|
||||
-- da riga di comando da' un errore fuorviante.)
|
||||
-- Oggi nessun longevity.db esiste ancora, quindi il caso e' teorico: lo diventa il
|
||||
-- giorno del primo deploy, ed e' quello il momento di ricordarselo.
|
||||
CHECK ((stato = 'ok' AND valore IS NOT NULL) OR (stato = 'insufficiente' AND valore IS NULL))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC);
|
||||
`;
|
||||
|
||||
const SCHEMA_IDENTITY = `
|
||||
CREATE TABLE IF NOT EXISTS clienti (
|
||||
client_code TEXT PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
nome TEXT NOT NULL,
|
||||
cognome TEXT NOT NULL,
|
||||
data_nascita TEXT,
|
||||
email TEXT,
|
||||
telefono TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_clienti_user ON clienti (user_id);
|
||||
`;
|
||||
|
||||
function apri(p: string, schema: string): Database.Database {
|
||||
if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
|
||||
const db = new Database(p);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(schema);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function createLongevityDb(path?: string): Database.Database {
|
||||
return apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY);
|
||||
}
|
||||
|
||||
export function createIdentityDb(path?: string): Database.Database {
|
||||
return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY);
|
||||
}
|
||||
|
||||
// Connessione singola per processo, come getDb() in src/lib/db.ts: gli endpoint chiedono
|
||||
// una connessione già aperta invece di aprirne una nuova a ogni richiesta.
|
||||
// ⚠️ Qui c'è SOLO longevity (il database senza nomi: può usarlo chiunque). Un singleton
|
||||
// analogo per identity non si esporta da qui — vive privato dentro anagrafica.ts, l'unico
|
||||
// modulo autorizzato a tenerlo: un controllo testuale file-per-file non vede una giunzione
|
||||
// che passa da un re-export su piu' file, quindi la connessione condivisa a identity non
|
||||
// deve essere raggiungibile da fuori quel modulo, punto.
|
||||
let longevitySingleton: Database.Database | null = null;
|
||||
|
||||
export function getLongevityDb(): Database.Database {
|
||||
if (!longevitySingleton) longevitySingleton = createLongevityDb();
|
||||
return longevitySingleton;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
//
|
||||
// L'export per le statistiche legge SOLO longevity.db, che per costruzione non contiene
|
||||
// nomi: non serve nessuna funzione di anonimizzazione, e non c'è niente da ricordarsi.
|
||||
// I campi liberi (farmaci, patologie) restano fuori: sono per il coach, non per l'analisi.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
const INTESTAZIONI = [
|
||||
'client_code', 'sesso', 'eta', 'data', 'tipo_sessione',
|
||||
'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range',
|
||||
];
|
||||
|
||||
export function esportaMisure(db: Database.Database): {
|
||||
intestazioni: string[];
|
||||
righe: (string | number | null)[][];
|
||||
} {
|
||||
const righe = db.prepare(
|
||||
`SELECT sg.client_code, sg.sesso, s.eta_alla_data, s.data, s.tipo, s.quest_version,
|
||||
m.test_id, COALESCE(m.valore_num, m.valore_txt) AS valore, m.unita, m.fonte, m.fuori_range
|
||||
FROM misure m
|
||||
JOIN sessioni s ON s.id = m.sessione_id
|
||||
JOIN soggetti sg ON sg.client_code = m.client_code
|
||||
ORDER BY s.data, m.id`
|
||||
).raw().all() as (string | number | null)[][];
|
||||
|
||||
return { intestazioni: INTESTAZIONI, righe };
|
||||
}
|
||||
|
||||
export function esportaCsv(db: Database.Database): string {
|
||||
const { intestazioni, righe } = esportaMisure(db);
|
||||
const cella = (v: string | number | null) => {
|
||||
if (v === null) return '';
|
||||
const s = String(v);
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
};
|
||||
return [intestazioni.join(','), ...righe.map((r) => r.map(cella).join(','))].join('\n') + '\n';
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// Formattazione dei numeri del referto. Un solo posto, perché prima ognuno stampava il
|
||||
// numero grezzo di JavaScript (punto, non virgola) direttamente nel template — e un
|
||||
// referto italiano con "72.5" al posto di "72,5" si legge come un difetto, non come un
|
||||
// dettaglio.
|
||||
|
||||
/** Un punteggio o una Fitness Age, formattati con la virgola decimale italiana.
|
||||
* Le ETICHETTE dei punteggi (PERFORMANCE/ENERGY/RECOVERY, nomi di prodotto) non si
|
||||
* toccano: solo i NUMERI si leggono all'italiana in un referto italiano. */
|
||||
export function formattaNumero(n: number): string {
|
||||
return n.toLocaleString('it-IT');
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { esisteTest } from './registro';
|
||||
|
||||
export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index';
|
||||
|
||||
export type MisuraIn = {
|
||||
test_id: string;
|
||||
valore_num?: number;
|
||||
valore_txt?: string;
|
||||
unita?: string;
|
||||
};
|
||||
|
||||
export function apriSessione(
|
||||
db: Database.Database,
|
||||
s: {
|
||||
client_code: string; data: string; tipo: 'checkup' | 'questionario';
|
||||
eta_alla_data?: number; operatore?: string; quest_version?: string;
|
||||
}
|
||||
): number {
|
||||
const r = db.prepare(
|
||||
`INSERT INTO sessioni (client_code, data, tipo, eta_alla_data, operatore, quest_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(s.client_code, s.data, s.tipo, s.eta_alla_data ?? null, s.operatore ?? null, s.quest_version ?? null);
|
||||
return Number(r.lastInsertRowid);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'unico punto in cui si scrive nella tabella `misure`.
|
||||
* Un test_id sconosciuto fa fallire l'intero lotto: meglio un errore subito che una
|
||||
* sessione scritta a metà. Un valore fuori dal range atteso invece si scrive e si
|
||||
* marca — scartarlo perderebbe un dato vero, clamparlo lo falserebbe.
|
||||
*/
|
||||
export function registraMisure(
|
||||
db: Database.Database,
|
||||
sessioneId: number,
|
||||
fonte: Fonte,
|
||||
misure: MisuraIn[]
|
||||
): { scritte: number; fuoriRange: string[] } {
|
||||
const sess = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
|
||||
{ client_code: string } | undefined;
|
||||
if (!sess) throw new Error(`sessione ${sessioneId} inesistente`);
|
||||
|
||||
for (const m of misure) {
|
||||
if (!esisteTest(db, m.test_id)) {
|
||||
throw new Error(`test_id non nel registro: ${m.test_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const range = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`);
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, valore_txt, unita, fonte, fuori_range)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const fuoriRange: string[] = [];
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const m of misure) {
|
||||
const r = range.get(m.test_id) as { range_min: number | null; range_max: number | null };
|
||||
let fuori = 0;
|
||||
if (m.valore_num !== undefined) {
|
||||
if ((r.range_min !== null && m.valore_num < r.range_min) ||
|
||||
(r.range_max !== null && m.valore_num > r.range_max)) {
|
||||
fuori = 1;
|
||||
fuoriRange.push(m.test_id);
|
||||
}
|
||||
}
|
||||
ins.run(sessioneId, sess.client_code, m.test_id,
|
||||
m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
return { scritte: misure.length, fuoriRange };
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* La cascata a quattro livelli: dalle sotto-metriche normalizzate ai
|
||||
* punteggi di sotto-dominio, ai sette assi del radar, ai tre macro-score
|
||||
* (Performance/Energy/Recovery), fino alla Fitness Age.
|
||||
*
|
||||
* Porting di `aggregate`, `compute_axis`, `compute_macro_scores` e
|
||||
* `compute_fitness_age` dall'oracolo del cliente
|
||||
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni LIVELLO 2-4).
|
||||
* Regola di copertura, uguale a ogni livello: se un elemento manca, il suo
|
||||
* peso si ridistribuisce su quelli disponibili; se la copertura di peso
|
||||
* disponibile scende sotto `COPERTURA_MINIMA`, l'elemento e' insufficiente.
|
||||
*
|
||||
* DUE DIVERGENZE VOLUTE dall'oracolo:
|
||||
*
|
||||
* 1) Nell'oracolo `aggregate()` restituisce il punteggio pieno anche quando lo
|
||||
* dichiara insufficiente (la docstring dice il contrario di cio' che il
|
||||
* codice fa) - chi consuma deve ricordarsi di guardare lo stato, e prima o
|
||||
* poi qualcuno non lo fa. Qui il tipo `Punteggio` chiude la trappola: nel
|
||||
* ramo 'insufficiente' il campo `valore` non esiste, quindi la regola di
|
||||
* prodotto - un asse con dati insufficienti si mostra tratteggiato, mai
|
||||
* con un numero pieno fasullo - e' impossibile da violare per distrazione.
|
||||
*
|
||||
* 2) Nel calcolo della Fitness Age (vedi `motore/index.ts`, `valoreAsse`)
|
||||
* escludiamo gli assi 'insufficiente' dal composito: `cardio`,
|
||||
* `forza_resto`, `composizione` e `stabilita` entrano solo se il rispettivo
|
||||
* asse e' 'ok'. L'oracolo invece li include comunque - per lo stesso motivo
|
||||
* del punto 1: `compute_axis` restituisce uno `score` numerico anche
|
||||
* quando lo stato e' "insufficiente", e `compute_fitness_age` lo riceve e
|
||||
* lo usa senza controllare lo stato. E' il comportamento GIUSTO (un asse
|
||||
* sotto soglia non e' un dato affidabile da far pesare sull'eta biologica),
|
||||
* ma cambia il numero: su un profilo con assi parzialmente insufficienti,
|
||||
* verificato a mano, la nostra Fitness Age e' 25,0 contro 34,8 dell'oracolo
|
||||
* - quasi dieci anni di scarto. Chi confronta il nostro referto col Python
|
||||
* del cliente su un profilo parziale concludera' che il porting e' rotto:
|
||||
* non lo e', diverge di proposito qui.
|
||||
*
|
||||
* Tutto il resto e' fedele all'oracolo.
|
||||
*/
|
||||
|
||||
import { COPERTURA_MINIMA } from '../db';
|
||||
import { arrotonda1 } from './curve';
|
||||
|
||||
/** Esito di un'aggregazione pesata: 'ok' porta il valore, 'insufficiente' no. */
|
||||
export type Punteggio =
|
||||
| { stato: 'ok'; valore: number; copertura: number }
|
||||
| { stato: 'insufficiente'; copertura: number };
|
||||
|
||||
/** Una voce da aggregare: punteggio 0-100 (o null se il dato manca) e il suo peso nominale. */
|
||||
export type VocePesata = { punteggio: number | null; peso: number };
|
||||
|
||||
/**
|
||||
* Media pesata delle voci disponibili, con i pesi delle voci mancanti
|
||||
* ridistribuiti su quelle presenti. Come `aggregate` nell'oracolo, con la
|
||||
* differenza di tipo descritta sopra.
|
||||
*
|
||||
* ATTENZIONE (bug corretto, trovato in revisione): il confronto con la soglia
|
||||
* usa la copertura GREZZA (`pesoDisponibile / pesoTotale`), non quella
|
||||
* arrotondata. L'arrotondamento a due decimali (`arrotonda1(.., 2)`) serve
|
||||
* solo al valore esposto nel campo `copertura` del risultato. Confrontare la
|
||||
* copertura gia' arrotondata avrebbe fatto passare per 'ok' una copertura
|
||||
* grezza appena sotto 0.40 che arrotonda a 0.40 (es. 0.396) - esattamente il
|
||||
* numero pieno fasullo che il tipo `Punteggio` esiste per impedire.
|
||||
*/
|
||||
export function aggrega(voci: VocePesata[]): Punteggio {
|
||||
const pesoTotale = voci.reduce((s, v) => s + v.peso, 0);
|
||||
const disponibili = voci.filter((v) => v.punteggio !== null);
|
||||
const pesoDisponibile = disponibili.reduce((s, v) => s + v.peso, 0);
|
||||
const coperturaGrezza = pesoTotale ? pesoDisponibile / pesoTotale : 0;
|
||||
const copertura = arrotonda1(coperturaGrezza, 2);
|
||||
|
||||
if (disponibili.length === 0) {
|
||||
return { stato: 'insufficiente', copertura: 0 };
|
||||
}
|
||||
|
||||
if (coperturaGrezza < COPERTURA_MINIMA) {
|
||||
return { stato: 'insufficiente', copertura };
|
||||
}
|
||||
|
||||
const sommaPesata = disponibili.reduce((s, v) => s + (v.punteggio as number) * v.peso, 0);
|
||||
const valore = arrotonda1(sommaPesata / pesoDisponibile);
|
||||
return { stato: 'ok', valore, copertura };
|
||||
}
|
||||
|
||||
/**
|
||||
* Livello 2: punteggi di sotto-dominio -> punteggio Asse. Come `compute_axis`
|
||||
* nell'oracolo: `pesi` e' la config del foglio "Pesi e Formule" per quell'asse,
|
||||
* `punteggi` i valori 0-100 disponibili (assenti = mancanti).
|
||||
*/
|
||||
export function calcolaAsse(pesi: Record<string, number>, punteggi: Record<string, number | null>): Punteggio {
|
||||
const voci: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({
|
||||
punteggio: punteggi[chiave] ?? null,
|
||||
peso,
|
||||
}));
|
||||
return aggrega(voci);
|
||||
}
|
||||
|
||||
/**
|
||||
* Livello 3: i sette Assi -> un macro-score (Performance/Energy/Recovery).
|
||||
* Come `compute_macro_scores` nell'oracolo: un asse insufficiente non entra
|
||||
* col suo valore (che qui non esiste nemmeno), entra come mancante e il suo
|
||||
* peso si ridistribuisce.
|
||||
*/
|
||||
export function calcolaMacro(pesiMacro: Record<string, number>, assi: Record<string, Punteggio>): Punteggio {
|
||||
const voci: VocePesata[] = Object.entries(pesiMacro).map(([asse, peso]) => {
|
||||
const p = assi[asse];
|
||||
return { punteggio: p && p.stato === 'ok' ? p.valore : null, peso };
|
||||
});
|
||||
return aggrega(voci);
|
||||
}
|
||||
|
||||
/**
|
||||
* Livello 4: Fitness Age. Come `compute_fitness_age` nell'oracolo: un
|
||||
* composito di sei elementi (handgrip e HRV isolati, non tramite l'intero
|
||||
* asse) da cui `eta - (composito - 50) * 0.4`. Se il composito e'
|
||||
* insufficiente non c'e' Fitness Age da mostrare.
|
||||
*
|
||||
* `etaAnagrafica` e' `number | null` perche' `sessioni.eta_alla_data` e'
|
||||
* nullable nello schema: l'apertura di una sessione e il salvataggio di un
|
||||
* questionario accettano l'eta come opzionale. Senza eta la Fitness Age NON
|
||||
* ESISTE - deve restituire `null`, mai un numero calcolato con un ripiego
|
||||
* (es. 0), che produrrebbe un'eta di forma negativa dichiarata valida. Gli
|
||||
* assi e i macro-score non dipendono dall'eta e restano calcolabili a monte:
|
||||
* solo questo ultimo livello si ferma.
|
||||
*/
|
||||
export function calcolaFitnessAge(
|
||||
etaAnagrafica: number | null,
|
||||
pesi: Record<string, number>,
|
||||
voci: Record<string, number | null>
|
||||
): { fitnessAge: number | null; composito: Punteggio } {
|
||||
const vociPesate: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({
|
||||
punteggio: voci[chiave] ?? null,
|
||||
peso,
|
||||
}));
|
||||
const composito = aggrega(vociPesate);
|
||||
if (composito.stato === 'insufficiente' || etaAnagrafica === null) {
|
||||
return { fitnessAge: null, composito };
|
||||
}
|
||||
const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4);
|
||||
return { fitnessAge, composito };
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* Curve di normalizzazione del questionario Longevity: portano una risposta
|
||||
* grezza a un punteggio 0-100.
|
||||
*
|
||||
* `clamp`, `lerp`, `curvaCampana`, `curvaDecrescente`, `curvaCrescenteConPlateau`
|
||||
* e `curvaDirettaX10` sono il porting letterale di `clamp`, `_lerp`,
|
||||
* `score_bell_curve`, `score_decreasing`, `score_increasing_plateau` e
|
||||
* `score_direct_x10` dell'oracolo del cliente
|
||||
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezione «Questionario:
|
||||
* curve generiche»). Non toccare quella logica: è verificata riga per riga
|
||||
* contro `riferimento.json`.
|
||||
*
|
||||
* `curvaDirettaX10Invertita` e `curvaGradini` NON sono nell'oracolo Python:
|
||||
* vengono dal prototipo HTML del cliente (`scale10_inv`, `decstep`/`incstep`)
|
||||
* e non hanno un oracolo di riferimento — sono coperte solo dai test scritti
|
||||
* a mano in `tests/longevity/motore-curve.test.ts`.
|
||||
*/
|
||||
|
||||
/** Limita x all'intervallo [lo, hi]. Come `clamp` nell'oracolo. */
|
||||
export function clamp(x: number, lo = 0, hi = 100): number {
|
||||
return Math.max(lo, Math.min(hi, x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolazione lineare fra (x0, y0) e (x1, y1). Il fattore t è limitato a
|
||||
* [0, 1]: la curva non esce mai oltre y0/y1, anche se x è fuori da [x0, x1].
|
||||
* Come `_lerp` nell'oracolo.
|
||||
*/
|
||||
export function lerp(x: number, x0: number, x1: number, y0: number, y1: number): number {
|
||||
if (x1 === x0) return y0;
|
||||
let t = (x - x0) / (x1 - x0);
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return y0 + t * (y1 - y0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrotonda a `decimali` cifre decimali (default 1), come `round(x, decimali)`
|
||||
* in Python: sui pareggi ESATTI (il valore binario del double è esattamente a
|
||||
* metà, es. 86.25 per un decimale) arrotonda al pari (banker's rounding), non
|
||||
* sempre in su come farebbe `Math.round`. Alcune curve a gradini producono
|
||||
* pareggi veri — `lerp` che restituisce 86.25 esatto va a 86.2, non 86.3.
|
||||
*
|
||||
* Non basta moltiplicare per 10^decimali e confrontare con una tolleranza: un
|
||||
* valore come 86.35 NON è un pareggio vero a un decimale (il double che gli
|
||||
* sta dietro è 86.34999999999999431..., quindi Python arrotonda a 86.3, non
|
||||
* a metà), ma `86.35 * 10` in virgola mobile arrotonda esattamente a 863.5 —
|
||||
* un pareggio fasullo creato dalla moltiplicazione, non presente nel valore
|
||||
* originale. Una prima versione con tolleranza ci cadeva (dava 86.4). Per
|
||||
* evitarlo si scompone il double nella sua mantissa ed esponente esatti (bit
|
||||
* IEEE 754, via `DataView`) e si confrontano `ax*10^decimali` e il pareggio
|
||||
* con aritmetica razionale su `BigInt`, senza mai passare per una
|
||||
* moltiplicazione in virgola mobile che potrebbe introdurre o cancellare un
|
||||
* pareggio.
|
||||
*
|
||||
* Unica funzione di arrotondamento del motore: la usano `curve.ts`,
|
||||
* `test-fisici.ts` (a un decimale, i punteggi) e `cascata.ts` (anche a due
|
||||
* decimali, la copertura), per non avere due implementazioni che sui pareggi
|
||||
* potrebbero divergere fra loro.
|
||||
*/
|
||||
export function arrotonda1(x: number, decimali = 1): number {
|
||||
if (!Number.isFinite(x) || x === 0) return x === 0 ? 0 : x;
|
||||
const negativo = x < 0;
|
||||
const ax = Math.abs(x);
|
||||
const potenza = 10n ** BigInt(decimali);
|
||||
|
||||
// Scompone il double nella sua rappresentazione esatta ax = mantissa * 2^exponente.
|
||||
const view = new DataView(new ArrayBuffer(8));
|
||||
view.setFloat64(0, ax);
|
||||
const hi = view.getUint32(0);
|
||||
const lo = view.getUint32(4);
|
||||
const expBits = (hi >>> 20) & 0x7ff;
|
||||
let mantissa = (BigInt(hi & 0xfffff) << 32n) | BigInt(lo);
|
||||
let exponent: number;
|
||||
if (expBits === 0) {
|
||||
exponent = -1074; // subnormale, non atteso su questi dati ma corretto comunque
|
||||
} else {
|
||||
mantissa |= 1n << 52n; // bit implicito
|
||||
exponent = expBits - 1075;
|
||||
}
|
||||
|
||||
// ax*10^decimali = num/den, con num e den interi esatti: nessuna
|
||||
// moltiplicazione in virgola mobile, quindi nessun pareggio fasullo
|
||||
// introdotto qui.
|
||||
let num: bigint;
|
||||
let den: bigint;
|
||||
if (exponent >= 0) {
|
||||
num = mantissa * (potenza << BigInt(exponent));
|
||||
den = 1n;
|
||||
} else {
|
||||
num = mantissa * potenza;
|
||||
den = 1n << BigInt(-exponent);
|
||||
}
|
||||
|
||||
const n = num / den; // floor(ax*10^decimali), esatto
|
||||
const resto2 = (num % den) * 2n; // confronta il resto con 1/2 di den, esatto
|
||||
let risultato: bigint;
|
||||
if (resto2 < den) risultato = n;
|
||||
else if (resto2 > den) risultato = n + 1n;
|
||||
else risultato = n % 2n === 0n ? n : n + 1n; // pareggio vero: arrotonda al pari
|
||||
|
||||
const valore = Number(risultato) / Number(potenza);
|
||||
return negativo ? -valore : valore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Curva a campana: punteggio massimo (100) fra peakLow e peakHigh, decresce
|
||||
* verso low/high ai lati. Come `score_bell_curve` nell'oracolo.
|
||||
*/
|
||||
export function curvaCampana(v: number, low: number, peakLow: number, peakHigh: number, high: number): number {
|
||||
if (v >= peakLow && v <= peakHigh) return 100;
|
||||
if (v < peakLow) return clamp(arrotonda1(lerp(v, low, peakLow, 10, 100)));
|
||||
return clamp(arrotonda1(lerp(v, peakHigh, high, 100, 10)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Curva decrescente: 100 a `best`, 0 a `worst`. Come `score_decreasing`
|
||||
* nell'oracolo — con `lerp` che limita t a [0,1], non scende sotto 0 né sale
|
||||
* sopra 100 fuori da [best, worst].
|
||||
*/
|
||||
export function curvaDecrescente(v: number, best: number, worst: number): number {
|
||||
return clamp(arrotonda1(lerp(v, best, worst, 100, 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Curva crescente con plateau: 20 a `worst`, 100 da `plateauStart` in poi.
|
||||
* Come `score_increasing_plateau` nell'oracolo.
|
||||
*/
|
||||
export function curvaCrescenteConPlateau(v: number, worst: number, plateauStart: number): number {
|
||||
return clamp(arrotonda1(lerp(v, worst, plateauStart, 20, 100)));
|
||||
}
|
||||
|
||||
/** Punteggio diretto: valore 0-10 moltiplicato per 10. Come `score_direct_x10` nell'oracolo. */
|
||||
export function curvaDirettaX10(v: number): number {
|
||||
return clamp(arrotonda1(v * 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* Complemento della diretta: valore 0-10, punteggio decrescente. Assente
|
||||
* nell'oracolo Python (è la quinta curva, quella che al motore del cliente
|
||||
* manca): serve a q_calo_pomeridiano, e nel prototipo HTML è `scale10_inv`,
|
||||
* cioè (10 - v) * 10.
|
||||
*/
|
||||
export function curvaDirettaX10Invertita(v: number): number {
|
||||
return clamp(arrotonda1((10 - v) * 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* Curva a gradini: interpola fra coppie [valore, punteggio] ordinate per
|
||||
* valore crescente, esattamente come `decstep`/`incstep` nel prototipo HTML
|
||||
* del cliente (assente nell'oracolo Python).
|
||||
*
|
||||
* Se v è zero e zeroVal è definito, vince zeroVal. Altrimenti si interpola a
|
||||
* tratti fra i gradini, partendo da un punto d'ancoraggio (0, 100) per le
|
||||
* curve decrescenti o (0, 20) per le crescenti, e restando sull'ultimo
|
||||
* gradino oltre la sua ascissa (tipicamente un valore-soglia molto alto che
|
||||
* funge da "infinito").
|
||||
*/
|
||||
export function curvaGradini(
|
||||
v: number,
|
||||
steps: [number, number][],
|
||||
zeroVal: number | undefined,
|
||||
decrescente: boolean
|
||||
): number {
|
||||
if (v === 0 && zeroVal !== undefined) return zeroVal;
|
||||
|
||||
const ancoraggio: [number, number] = [0, decrescente ? 100 : 20];
|
||||
const punti: [number, number][] = [ancoraggio, ...steps];
|
||||
|
||||
for (let i = 0; i < punti.length - 1; i++) {
|
||||
const [x0, y0] = punti[i];
|
||||
const [x1, y1] = punti[i + 1];
|
||||
if (v <= x1) {
|
||||
return clamp(arrotonda1(lerp(v, x0, x1, y0, y1)));
|
||||
}
|
||||
}
|
||||
// v oltre l'ultimo gradino: resta sull'ultimo valore.
|
||||
return punti[punti.length - 1][1];
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
/**
|
||||
* Il motore che legge dal registro, non da costanti: curve, parametri e pesi
|
||||
* sono dato (`registro_test`, `pesi`), non codice. Aggiungere o togliere un
|
||||
* test, o cambiare un parametro, resta una modifica ai dati.
|
||||
*
|
||||
* Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel
|
||||
* registro (`registro_test.curva`), coi parametri dichiarati lì
|
||||
* (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un
|
||||
* test senza curva dichiarata (es. un campo testuale) non contribuisce al
|
||||
* calcolo.
|
||||
*
|
||||
* Livelli 2-4 (`calcolaSessione`): raggruppa le misure attive della sessione
|
||||
* per sotto-dominio (media dei test dello stesso sotto-dominio — più test
|
||||
* possono contribuire allo stesso sotto-dominio, es. spinta via push-up o
|
||||
* panca), poi usa `cascata.ts` (`calcolaAsse`, `calcolaMacro`,
|
||||
* `calcolaFitnessAge`) coi pesi letti da `pesi` (via `pesiDi`) per i sette
|
||||
* assi, i tre macro-score e la Fitness Age.
|
||||
*
|
||||
* `salvaScore` congela il risultato nella tabella `score`, una riga per
|
||||
* asse/macro/Fitness Age, con `valore` a `null` quando lo stato è
|
||||
* insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo).
|
||||
* `leggiScore` fa il percorso inverso — legge `score` e la ritipa come
|
||||
* `Punteggio` — perché nessun altro punto del codice deve fare quella query
|
||||
* a mano su una colonna nullable.
|
||||
*
|
||||
* Tre barriere aggiunte in revisione, tutte nella direzione "dato insufficiente
|
||||
* → null, mai un numero fasullo": l'età mancante (nullable nello schema) non
|
||||
* usa più un ripiego a zero — senza età la Fitness Age è `null`, non un'età di
|
||||
* forma negativa; `applicaCurva` valida i parametri della curva prima di
|
||||
* applicarla, e restituisce `null` se il registro (dato modificabile senza
|
||||
* migrazioni) ha una riga incompleta, invece del punteggio pieno che dava
|
||||
* `lerp` con estremi indefiniti; e la lettura delle misure esclude
|
||||
* `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma
|
||||
* (punto aperto in §12).
|
||||
*/
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import {
|
||||
curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
|
||||
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, arrotonda1,
|
||||
} from './curve';
|
||||
import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata';
|
||||
import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro';
|
||||
|
||||
/** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */
|
||||
type ParamsCurva = {
|
||||
low?: number; peakLow?: number; peakHigh?: number; high?: number;
|
||||
best?: number; worst?: number; plateauStart?: number;
|
||||
steps?: [number, number][]; zeroVal?: number;
|
||||
};
|
||||
|
||||
/** Vero solo per un numero finito: esclude `undefined`, `NaN`, stringhe, ecc. */
|
||||
function numeroValido(x: unknown): x is number {
|
||||
return typeof x === 'number' && Number.isFinite(x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica che `params` contenga tutto cio' che la curva dichiarata richiede,
|
||||
* PRIMA di passarlo alla funzione di curva. Il registro e' dato modificabile
|
||||
* senza migrazioni (la promessa della spec, §5): una riga con `params` vuoti
|
||||
* o incompleti deve fermarsi qui, non arrivare a `lerp` con estremi
|
||||
* indefiniti - dove `x1 === x0` (`undefined === undefined`) restituisce il
|
||||
* punteggio pieno, la peggior direzione di guasto possibile per un motore
|
||||
* clinico.
|
||||
*/
|
||||
function paramsValidi(curva: VoceRegistro['curva'], p: ParamsCurva): boolean {
|
||||
switch (curva) {
|
||||
case 'bell':
|
||||
return numeroValido(p.low) && numeroValido(p.peakLow) && numeroValido(p.peakHigh) && numeroValido(p.high);
|
||||
case 'lin_dec':
|
||||
return numeroValido(p.best) && numeroValido(p.worst);
|
||||
case 'inc_plateau':
|
||||
return numeroValido(p.worst) && numeroValido(p.plateauStart);
|
||||
case 'x10':
|
||||
case 'x10_inv':
|
||||
return true; // nessun parametro richiesto
|
||||
case 'decstep':
|
||||
case 'incstep':
|
||||
return Array.isArray(p.steps) && p.steps.length > 0 &&
|
||||
p.steps.every((s) => Array.isArray(s) && s.length === 2 && numeroValido(s[0]) && numeroValido(s[1]));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizza un valore grezzo secondo la curva dichiarata nel registro per
|
||||
* quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel
|
||||
* motore. Un test senza curva dichiarata restituisce `null`: non contribuisce
|
||||
* al calcolo di sotto-dominio. Lo stesso vale per una curva coi parametri
|
||||
* mancanti o incompleti: mai un punteggio inventato (vedi `paramsValidi`).
|
||||
*/
|
||||
export function applicaCurva(voce: VoceRegistro, valore: number): number | null {
|
||||
if (!voce.curva) return null;
|
||||
const p = (voce.params ?? {}) as ParamsCurva;
|
||||
if (!paramsValidi(voce.curva, p)) return null;
|
||||
switch (voce.curva) {
|
||||
case 'bell':
|
||||
return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number);
|
||||
case 'lin_dec':
|
||||
return curvaDecrescente(valore, p.best as number, p.worst as number);
|
||||
case 'inc_plateau':
|
||||
return curvaCrescenteConPlateau(valore, p.worst as number, p.plateauStart as number);
|
||||
case 'x10':
|
||||
return curvaDirettaX10(valore);
|
||||
case 'x10_inv':
|
||||
return curvaDirettaX10Invertita(valore);
|
||||
case 'decstep':
|
||||
return curvaGradini(valore, p.steps ?? [], p.zeroVal, true);
|
||||
case 'incstep':
|
||||
return curvaGradini(valore, p.steps ?? [], p.zeroVal, false);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age.
|
||||
* `fitnessAge` è `number | null` — non `Punteggio` come gli assi e i macro-score — perché
|
||||
* e' cosi' che la produce `calcolaFitnessAge` in `cascata.ts` (l'oracolo del cliente la
|
||||
* tratta come un numero singolo, non come un contenitore stato/valore): `null` quando il
|
||||
* composito e' insufficiente. `fitnessAgeCopertura` porta la copertura del composito, che
|
||||
* altrimenti andrebbe persa: serve a `salvaScore` per scrivere la riga 'fitness_age'.
|
||||
*/
|
||||
export type RisultatoSessione = {
|
||||
assi: Record<string, Punteggio>;
|
||||
macro: Record<string, Punteggio>;
|
||||
fitnessAge: number | null;
|
||||
fitnessAgeCopertura: number;
|
||||
modelVersion: string;
|
||||
questVersion: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da
|
||||
* costanti: `testAttivi` alla data della sessione, i valori grezzi da
|
||||
* `misure`, i pesi da `pesi` (tramite `pesiDi`).
|
||||
*/
|
||||
export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione {
|
||||
const sessione = db.prepare(
|
||||
`SELECT data, eta_alla_data, quest_version FROM sessioni WHERE id = ?`
|
||||
).get(sessioneId) as { data: string; eta_alla_data: number | null; quest_version: string | null } | undefined;
|
||||
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
|
||||
|
||||
const attivi = testAttivi(db, sessione.data);
|
||||
// fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno
|
||||
// non le conferma". La colonna della conferma non esiste ancora (punto aperto in
|
||||
// §12 della spec) - quando ci sara', questo filtro andra' rilassato per lasciar
|
||||
// passare le misure confermate, non tolto del tutto.
|
||||
const misurate = new Map<string, number>(
|
||||
(db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ? AND fuori_range = 0`).all(sessioneId) as
|
||||
{ test_id: string; valore_num: number | null }[])
|
||||
.filter((m) => m.valore_num !== null)
|
||||
.map((m) => [m.test_id, m.valore_num as number])
|
||||
);
|
||||
|
||||
// Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per
|
||||
// sotto-dominio — più test nello stesso sotto-dominio contano come media.
|
||||
const perSottoDominio = new Map<string, number[]>();
|
||||
for (const voce of attivi) {
|
||||
if (!voce.sotto_dominio) continue;
|
||||
const grezzo = misurate.get(voce.test_id);
|
||||
if (grezzo === undefined) continue;
|
||||
const punteggio = applicaCurva(voce, grezzo);
|
||||
if (punteggio === null) continue;
|
||||
const lista = perSottoDominio.get(voce.sotto_dominio) ?? [];
|
||||
lista.push(punteggio);
|
||||
perSottoDominio.set(voce.sotto_dominio, lista);
|
||||
}
|
||||
const sottoDominio: Record<string, number> = {};
|
||||
for (const [nome, valori] of perSottoDominio) {
|
||||
sottoDominio[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length);
|
||||
}
|
||||
|
||||
// Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso.
|
||||
const nomiAssi = (db.prepare(
|
||||
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse'`
|
||||
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
|
||||
|
||||
const assi: Record<string, Punteggio> = {};
|
||||
for (const nomeAsse of nomiAssi) {
|
||||
assi[nomeAsse] = calcolaAsse(pesiDi(db, MODEL_VERSION, 'asse', nomeAsse), sottoDominio);
|
||||
}
|
||||
|
||||
// Livello 3: i tre macro-score (Performance/Energy/Recovery).
|
||||
const nomiMacro = (db.prepare(
|
||||
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'macro'`
|
||||
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
|
||||
|
||||
const macro: Record<string, Punteggio> = {};
|
||||
for (const nomeMacro of nomiMacro) {
|
||||
macro[nomeMacro] = calcolaMacro(pesiDi(db, MODEL_VERSION, 'macro', nomeMacro), assi);
|
||||
}
|
||||
|
||||
// Livello 4: Fitness Age. cardio/forza_resto/composizione/stabilita vengono
|
||||
// dal punteggio intero dell'asse; handgrip_isolato e hrv_isolato dal
|
||||
// sotto-dominio isolato, prima che entri nell'aggregazione del suo asse —
|
||||
// come nell'oracolo, che guarda quei due sotto-domini da soli.
|
||||
const valoreAsse = (nome: string): number | null => {
|
||||
const a = assi[nome];
|
||||
return a && a.stato === 'ok' ? a.valore : null;
|
||||
};
|
||||
const vociFitnessAge: Record<string, number | null> = {
|
||||
cardio: valoreAsse('Cardio-Respiratorio'),
|
||||
handgrip_isolato: sottoDominio.handgrip ?? null,
|
||||
hrv_isolato: sottoDominio.hrv ?? null,
|
||||
forza_resto: valoreAsse('Forza & Struttura'),
|
||||
composizione: valoreAsse('Composizione Corporea'),
|
||||
stabilita: valoreAsse('Stabilità & Mobilità Funzionale'),
|
||||
};
|
||||
const { fitnessAge, composito } = calcolaFitnessAge(
|
||||
sessione.eta_alla_data,
|
||||
pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'),
|
||||
vociFitnessAge
|
||||
);
|
||||
|
||||
return {
|
||||
assi, macro, fitnessAge, fitnessAgeCopertura: composito.copertura,
|
||||
modelVersion: MODEL_VERSION, questVersion: sessione.quest_version,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Congela il risultato di `calcolaSessione` nella tabella `score`: una riga
|
||||
* per asse, una per macro-score, una per la Fitness Age. `valore` va a
|
||||
* `null` quando lo stato è insufficiente — il tipo `Punteggio` non porta un
|
||||
* `valore` in quel ramo, quindi non c'è modo di scrivere un numero fasullo.
|
||||
*/
|
||||
export function salvaScore(db: Database.Database, sessioneId: number, risultato: RisultatoSessione): void {
|
||||
const sessione = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
|
||||
{ client_code: string } | undefined;
|
||||
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
|
||||
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, quest_version, model_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
const riga = (tipo: 'asse' | 'macro' | 'fitness_age', elemento: string, p: Punteggio): void => {
|
||||
ins.run(
|
||||
sessione.client_code, sessioneId, tipo, elemento,
|
||||
p.stato === 'ok' ? p.valore : null,
|
||||
p.copertura, p.stato, risultato.questVersion, risultato.modelVersion
|
||||
);
|
||||
};
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const [nome, p] of Object.entries(risultato.assi)) riga('asse', nome, p);
|
||||
for (const [nome, p] of Object.entries(risultato.macro)) riga('macro', nome, p);
|
||||
const fitnessAgePunteggio: Punteggio = risultato.fitnessAge === null
|
||||
? { stato: 'insufficiente', copertura: risultato.fitnessAgeCopertura }
|
||||
: { stato: 'ok', valore: risultato.fitnessAge, copertura: risultato.fitnessAgeCopertura };
|
||||
riga('fitness_age', 'fitness_age', fitnessAgePunteggio);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
/** Esito di `leggiScore`: gli assi, i macro-score e la Fitness Age, tipati come `Punteggio`. */
|
||||
export type PunteggiSessione = {
|
||||
assi: Record<string, Punteggio>;
|
||||
macro: Record<string, Punteggio>;
|
||||
/** `null` = nessun calcolo mai salvato per questa sessione (non un composito insufficiente). */
|
||||
fitnessAge: Punteggio | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legge i punteggi congelati di una sessione dalla tabella `score`, tipati come
|
||||
* `Punteggio` — la stessa unione discriminata del resto del motore. Nessuno
|
||||
* legge ancora `score` (la scrive solo `salvaScore`): la prima query cruda che
|
||||
* l'interfaccia scriverà su una colonna nullable perderebbe la barriera del
|
||||
* tipo se restasse un semplice `SELECT *`. Qui non la perde.
|
||||
*
|
||||
* `salvaScore` non ha vincolo di unicità di proposito — la storia si tiene,
|
||||
* non si sovrascrive (discende dal congelamento della §6: sovrascrivere
|
||||
* cancellerebbe cio' che il congelamento protegge). Una sessione ricalcolata
|
||||
* più volte ha quindi più righe per lo stesso tipo/elemento: questa query
|
||||
* dichiara ESPLICITAMENTE il criterio dell'ultimo calcolo (il `MAX(id)` per
|
||||
* tipo+elemento, non l'ordine naturale con cui SQLite restituisce le righe,
|
||||
* che non è un criterio).
|
||||
*/
|
||||
export function leggiScore(db: Database.Database, sessioneId: number): PunteggiSessione {
|
||||
const righe = db.prepare(
|
||||
`SELECT s.tipo, s.elemento, s.valore, s.copertura, s.stato
|
||||
FROM score s
|
||||
JOIN (
|
||||
SELECT tipo, elemento, MAX(id) AS ultimo_id
|
||||
FROM score
|
||||
WHERE sessione_id = ?
|
||||
GROUP BY tipo, elemento
|
||||
) ultimo ON ultimo.tipo = s.tipo AND ultimo.elemento = s.elemento AND ultimo.ultimo_id = s.id
|
||||
WHERE s.sessione_id = ?`
|
||||
).all(sessioneId, sessioneId) as
|
||||
{ tipo: 'asse' | 'macro' | 'fitness_age'; elemento: string; valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }[];
|
||||
|
||||
const aPunteggio = (r: { valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }): Punteggio =>
|
||||
r.stato === 'ok' ? { stato: 'ok', valore: r.valore as number, copertura: r.copertura } : { stato: 'insufficiente', copertura: r.copertura };
|
||||
|
||||
const assi: Record<string, Punteggio> = {};
|
||||
const macro: Record<string, Punteggio> = {};
|
||||
let fitnessAge: Punteggio | null = null;
|
||||
for (const r of righe) {
|
||||
if (r.tipo === 'asse') assi[r.elemento] = aPunteggio(r);
|
||||
else if (r.tipo === 'macro') macro[r.elemento] = aPunteggio(r);
|
||||
else fitnessAge = aPunteggio(r);
|
||||
}
|
||||
return { assi, macro, fitnessAge };
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
/**
|
||||
* Curve dei test fisici: da misura grezza (kg al dinamometro, secondi di
|
||||
* plank, VO2max stimato...) a punteggio 0-100.
|
||||
*
|
||||
* Porting letterale delle funzioni dell'oracolo del cliente
|
||||
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni «Composizione
|
||||
* Corporea», «Cardio-Respiratorio», «Recupero & Sistema Nervoso», «Forza &
|
||||
* Struttura», «Stabilità & Mobilità»). Coefficienti, soglie e tabelle sono
|
||||
* copiati tali e quali e verificati contro `riferimento.json` in
|
||||
* `tests/longevity/motore-fisici.test.ts`. Non "aggiustare" nulla qui dentro:
|
||||
* l'oracolo è la definizione, non una proposta.
|
||||
*
|
||||
* `score_agility_ms` e `score_generic_range_local` NON sono portate: la prima
|
||||
* perché il cliente ha rimosso l'agilità dallo score il 13/08 (scale non
|
||||
* comparabili fra protocolli), la seconda perché non è mai chiamata da
|
||||
* nessun peso.
|
||||
*
|
||||
* ⚠️ NON COLLEGATE AL MOTORE. Queste venti funzioni sono corrette e verificate
|
||||
* contro l'oracolo (`tests/longevity/motore-fisici.test.ts`), ma oggi sono
|
||||
* irraggiungibili in produzione: `applicaCurva` (`motore/index.ts`) smista solo
|
||||
* sulle sette curve generiche dichiarate nel vocabolario di `registro.ts`
|
||||
* (`bell`, `lin_dec`, `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e
|
||||
* non conosce nessun `test_id` fisico; e la sua firma prende `(voce, valore)`,
|
||||
* senza modo di passare età e sesso, che quasi tutte queste funzioni
|
||||
* richiedono. Agganciarle è lavoro del piano degli import, non di questa
|
||||
* revisione, e richiederà di ESTENDERE il vocabolario delle curve e di far
|
||||
* arrivare età/sesso fino ad `applicaCurva` — quindi non sarà "una modifica ai
|
||||
* dati" nel senso in cui lo intende la spec (registro senza migrazioni): serve
|
||||
* anche codice nuovo nel motore, non solo righe nuove in `registro_test`.
|
||||
*/
|
||||
|
||||
import { clamp, lerp, arrotonda1 } from './curve';
|
||||
|
||||
export type Sesso = 'M' | 'F';
|
||||
|
||||
/** Come `sex.strip().lower().startswith("m")` nell'oracolo. */
|
||||
function eMaschio(sesso: Sesso): boolean {
|
||||
return sesso.trim().toLowerCase().startsWith('m');
|
||||
}
|
||||
|
||||
// --- Composizione Corporea -------------------------------------------------
|
||||
|
||||
/** Curva a campana ACE. Il picco è su Atleti/Fitness, non sul grasso più basso. Come `score_fat_percent`. */
|
||||
export function scoreGrassoPercento(fatPct: number, sesso: Sesso): number {
|
||||
const m = eMaschio(sesso);
|
||||
const bands: [number, number, number][] = m
|
||||
? [[0, 2, 40], [2, 5, 65], [5, 13, 100], [13, 17, 90], [17, 24, 65], [24, 35, 30], [35, 200, 10]]
|
||||
: [[0, 10, 40], [10, 13, 65], [13, 20, 100], [20, 24, 90], [24, 31, 65], [31, 40, 30], [40, 200, 10]];
|
||||
for (const [lo, hi, val] of bands) {
|
||||
if (lo <= fatPct && fatPct < hi) return val;
|
||||
}
|
||||
return 50;
|
||||
}
|
||||
|
||||
/** Crescente con plateau: uomini 42.9-52.4%, donne 37.8-46.2% (standard device). Come `score_muscle_percent`. */
|
||||
export function scoreMuscoloPercento(musclePct: number, sesso: Sesso): number {
|
||||
const [lo, hi] = eMaschio(sesso) ? [42.9, 52.4] : [37.8, 46.2];
|
||||
if (musclePct >= hi) return 100;
|
||||
if (musclePct <= lo - 10) return 20;
|
||||
return arrotonda1(lerp(musclePct, lo - 10, hi, 20, 100));
|
||||
}
|
||||
|
||||
/** Come `score_whr`. */
|
||||
export function scoreWhr(whr: number, sesso: Sesso): number {
|
||||
const threshold = eMaschio(sesso) ? 0.9 : 0.85;
|
||||
if (whr <= threshold - 0.1) return 100;
|
||||
if (whr <= threshold) return arrotonda1(lerp(whr, threshold - 0.1, threshold, 100, 60));
|
||||
return clamp(arrotonda1(60 - (whr - threshold) * 300));
|
||||
}
|
||||
|
||||
// --- Cardio-Respiratorio ----------------------------------------------------
|
||||
|
||||
/** Queen's College Step Test (McArdle et al. 1972). Come `vo2max_from_step_test`. */
|
||||
export function vo2maxDaStepTest(hrRecovery: number, sesso: Sesso): number {
|
||||
if (eMaschio(sesso)) return 111.33 - 0.42 * hrRecovery;
|
||||
return 65.81 - 0.1847 * hrRecovery;
|
||||
}
|
||||
|
||||
/** UKK 2km Walking Test (Laukkanen/Oja). Come `vo2max_from_2km_walk`. */
|
||||
export function vo2maxDa2kmWalk(tempoMin: number, hr: number, eta: number, bmi: number): number {
|
||||
return 116.2 - 2.98 * tempoMin - 0.11 * hr - 0.14 * eta - 0.39 * bmi;
|
||||
}
|
||||
|
||||
/** Formula ACSM su VAM raggiunta al tapis. Teorico/stimato. Come `vo2max_from_mutt`. */
|
||||
export function vo2maxDaMutt(vamMMin: number): number {
|
||||
return 0.2 * vamMMin + 3.5;
|
||||
}
|
||||
|
||||
/** Formula ACSM cicloergometro. Teorico/stimato, valida 50-200W. Come `vo2max_from_milfit`. */
|
||||
export function vo2maxDaMilfit(watt: number, pesoKg: number): number {
|
||||
return (10.8 * watt) / pesoKg + 7;
|
||||
}
|
||||
|
||||
/** ACSM/FRIEND Registry, bande approssimate per fascia d'età e sesso. Come `score_vo2max`. */
|
||||
export function scoreVo2max(vo2max: number, eta: number, sesso: Sesso): number {
|
||||
const m = eMaschio(sesso);
|
||||
// soglie 'buono' per decade (uomini), interpolate da FRIEND Registry
|
||||
const goodThresholdsM: Record<number, number> = { 20: 46, 30: 43, 40: 42, 50: 38, 60: 35, 70: 30 };
|
||||
const goodThresholdsF: Record<number, number> = { 20: 38, 30: 37, 40: 34, 50: 30, 60: 27, 70: 23 };
|
||||
const table = m ? goodThresholdsM : goodThresholdsF;
|
||||
const decade = Math.min(70, Math.max(20, Math.floor(eta / 10) * 10));
|
||||
const good = table[decade];
|
||||
const poor = good * 0.65;
|
||||
const superior = good * 1.35;
|
||||
if (vo2max <= poor) return clamp(arrotonda1(lerp(vo2max, 0, poor, 10, 40)));
|
||||
if (vo2max <= good) return arrotonda1(lerp(vo2max, poor, good, 40, 70));
|
||||
return clamp(arrotonda1(lerp(vo2max, good, superior, 70, 100)));
|
||||
}
|
||||
|
||||
/** Saturazione O2: 95-100% normale, sotto 90% campanello d'allarme clinico. Come `score_spo2`. */
|
||||
export function scoreSpo2(spo2Pct: number): number {
|
||||
if (spo2Pct >= 97) return 100;
|
||||
if (spo2Pct >= 95) return 85;
|
||||
if (spo2Pct >= 90) return 50;
|
||||
return 15;
|
||||
}
|
||||
|
||||
// --- Recupero & Sistema Nervoso --------------------------------------------
|
||||
|
||||
/** ESC/ESH 2018. Vale la categoria più alta tra sistolica e diastolica. Come `score_blood_pressure`. */
|
||||
export function scorePressione(sistolica: number, diastolica: number): number {
|
||||
if (sistolica >= 180 || diastolica >= 110) return 5;
|
||||
if (sistolica >= 160 || diastolica >= 100) return 25;
|
||||
if (sistolica >= 140 || diastolica >= 90) return 45;
|
||||
if (sistolica >= 130 || diastolica >= 85) return 70;
|
||||
if (sistolica >= 120 || diastolica >= 80) return 90;
|
||||
return 100;
|
||||
}
|
||||
|
||||
/** Recupero cardiaco 1' post-sforzo. >=12bpm considerato normale (Cole et al. 1999). Come `score_hrr`. */
|
||||
export function scoreRecuperoCardiaco(caloBpm1min: number): number {
|
||||
if (caloBpm1min >= 12) return clamp(arrotonda1(lerp(caloBpm1min, 12, 30, 70, 100)));
|
||||
return clamp(arrotonda1(lerp(caloBpm1min, 0, 12, 20, 70)));
|
||||
}
|
||||
|
||||
// --- Forza & Struttura -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Approssimazione da NIH Toolbox / Dodds et al. Curva discendente con l'età.
|
||||
* Come `score_handgrip`.
|
||||
*
|
||||
* L'oracolo usa un `for...else` di Python: se il ciclo trova l'intervallo fra
|
||||
* due ancore, fa `break` con `mean_val` calcolato; se lo esaurisce senza mai
|
||||
* entrare nel ramo, l'`else` del `for` scatta e vale l'ultima ancora. In
|
||||
* TypeScript non esiste il for-else: lo si riproduce con un flag.
|
||||
*/
|
||||
export function scoreHandgrip(kg: number, eta: number, sesso: Sesso): number {
|
||||
const m = eMaschio(sesso);
|
||||
// ancore (età, valore medio kg) approssimate dalla letteratura citata
|
||||
const anchorsM: [number, number][] = [[25, 49.7], [40, 46], [60, 38], [75, 30]];
|
||||
const anchorsF: [number, number][] = [[25, 30], [40, 28], [60, 24], [75, 18.7]];
|
||||
const anchors = m ? anchorsM : anchorsF;
|
||||
const etaC = clamp(eta, 25, 75);
|
||||
let meanVal: number | undefined;
|
||||
for (let i = 0; i < anchors.length - 1; i++) {
|
||||
const [aEta, aVal] = anchors[i];
|
||||
const [bEta, bVal] = anchors[i + 1];
|
||||
if (aEta <= etaC && etaC <= bEta) {
|
||||
meanVal = lerp(etaC, aEta, bEta, aVal, bVal);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (meanVal === undefined) meanVal = anchors[anchors.length - 1][1];
|
||||
// media = punteggio 70; +/- 40% della media copre la banda 20-100
|
||||
const ratio = meanVal ? kg / meanVal : 1;
|
||||
return clamp(arrotonda1(lerp(ratio, 0.5, 1.5, 20, 100)));
|
||||
}
|
||||
|
||||
/** ACSM/CSEP, bande approssimate per decade. Come `score_pushup`. */
|
||||
export function scorePushup(reps: number, eta: number, sesso: Sesso): number {
|
||||
const m = eMaschio(sesso);
|
||||
const baseGoodM = 22;
|
||||
const baseGoodF = 15; // 35-39 anni, 'buono' minimo
|
||||
const decadeOffset = Math.max(0, Math.floor((eta - 35) / 10)) * 2.5; // calo ~2.5 rip/decade dopo i 35
|
||||
const good = (m ? baseGoodM : baseGoodF) - decadeOffset;
|
||||
const superior = good * 1.6;
|
||||
const poor = good * 0.5;
|
||||
if (reps <= poor) return clamp(arrotonda1(lerp(reps, 0, poor, 10, 40)));
|
||||
if (reps <= good) return arrotonda1(lerp(reps, poor, good, 40, 70));
|
||||
return clamp(arrotonda1(lerp(reps, good, superior, 70, 100)));
|
||||
}
|
||||
|
||||
/** Generico per 5RM->1RM(Brzycki)->rapporto peso corporeo, su tiers (beg/nov/int/adv/elite). Come `score_bw_ratio_lift`. */
|
||||
export function scoreSollevamentoSuPeso(
|
||||
caricoKg: number,
|
||||
pesoKg: number,
|
||||
rip: number,
|
||||
tiersM: [number, number][],
|
||||
tiersF: [number, number][],
|
||||
sesso: Sesso
|
||||
): number {
|
||||
const rm1 = (caricoKg * 36) / (37 - rip); // Brzycki
|
||||
const ratio = rm1 / pesoKg;
|
||||
const tiers = eMaschio(sesso) ? tiersM : tiersF;
|
||||
// tiers = [(soglia_ratio, punteggio), ...] crescente
|
||||
let prevR = 0;
|
||||
let prevS = 10;
|
||||
for (const [r, s] of tiers) {
|
||||
if (ratio <= r) return arrotonda1(lerp(ratio, prevR, r, prevS, s));
|
||||
prevR = r;
|
||||
prevS = s;
|
||||
}
|
||||
return 100;
|
||||
}
|
||||
|
||||
export const TIERS_BENCH_M: [number, number][] = [[0.5, 30], [1.0, 60], [1.25, 80], [1.5, 100]];
|
||||
export const TIERS_BENCH_F: [number, number][] = [[0.3, 30], [0.6, 60], [0.75, 80], [1.0, 100]];
|
||||
export const TIERS_SQUAT_M: [number, number][] = [[0.75, 30], [1.5, 60], [1.75, 80], [2.0, 100]];
|
||||
export const TIERS_SQUAT_F: [number, number][] = [[0.5, 30], [1.1, 60], [1.3, 80], [1.5, 100]];
|
||||
export const TIERS_ROW_M: [number, number][] = [[0.45, 30], [0.70, 60], [0.95, 80], [1.20, 100]];
|
||||
export const TIERS_ROW_F: [number, number][] = [[0.30, 30], [0.45, 60], [0.60, 80], [0.80, 100]];
|
||||
|
||||
/** Come `score_flexed_arm_hang`. */
|
||||
export function scoreTrazioneIsometrica(sec: number, sesso: Sesso): number {
|
||||
const [good, superior] = eMaschio(sesso) ? [45, 70] : [25, 50];
|
||||
if (sec <= good * 0.4) return clamp(arrotonda1(lerp(sec, 0, good * 0.4, 10, 40)));
|
||||
if (sec <= good) return arrotonda1(lerp(sec, good * 0.4, good, 40, 70));
|
||||
return clamp(arrotonda1(lerp(sec, good, superior, 70, 100)));
|
||||
}
|
||||
|
||||
/** Reference equation adulti 18-95 (Zalewski et al.-style). Come `score_sit_to_stand_1min`. */
|
||||
export function scoreSitToStand(reps: number, eta: number, sesso: Sesso, bmi = 24): number {
|
||||
const sexCode = eMaschio(sesso) ? 0 : 1;
|
||||
const predicted = 61.53 - 0.34 * eta - 3.57 * sexCode - 0.33 * bmi;
|
||||
const ratio = predicted ? reps / predicted : 1;
|
||||
return clamp(arrotonda1(lerp(ratio, 0.5, 1.3, 20, 100)));
|
||||
}
|
||||
|
||||
/** Come `score_plank`. */
|
||||
export function scorePlank(sec: number, sesso: Sesso): number {
|
||||
const bandsM: [number, number][] = [[79, 20], [97, 40], [122, 55], [157, 75], [201, 90]];
|
||||
const bandsF: [number, number][] = [[35, 20], [63, 40], [84, 55], [108, 75], [142, 90]];
|
||||
const bands = eMaschio(sesso) ? bandsM : bandsF;
|
||||
let prevT = 0;
|
||||
let prevS = 10;
|
||||
for (const [t, s] of bands) {
|
||||
if (sec <= t) return arrotonda1(lerp(sec, prevT, t, prevS, s));
|
||||
prevT = t;
|
||||
prevS = s;
|
||||
}
|
||||
return 100;
|
||||
}
|
||||
|
||||
// --- Stabilità & Mobilità ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Rikli & Jones (Senior Fitness Test) + studio norvegese per fascia under 60.
|
||||
* Positivo=sovrapposizione dita, negativo=distanza. Anchor a 62 anni (dato
|
||||
* solido), estrapolato linearmente (~4cm/5 anni) per le altre età.
|
||||
* Approssimazione da dichiarare, non tabella completa. Come `score_back_scratch`.
|
||||
*/
|
||||
export function scoreBackScratch(cm: number, eta: number, sesso: Sesso): number {
|
||||
const m = eMaschio(sesso);
|
||||
const anchorEta = 62;
|
||||
const anchorCm = m ? -8.6 : -1.8;
|
||||
const declinePerYear = 0.8; // cm peggioramento per anno di età in più
|
||||
const expected = anchorCm - (eta - anchorEta) * declinePerYear;
|
||||
// punteggio: 0/pieno contatto (0cm) o sovrapposizione (positivo) = ottimo
|
||||
const diff = cm - expected;
|
||||
return clamp(arrotonda1(lerp(diff, -15, 15, 20, 100)));
|
||||
}
|
||||
|
||||
/** Wellness Tower, riferimento 180 gradi (outreach/buckling). Come `score_shoulder_mobility_wt`. */
|
||||
export function scoreMobilitaSpalla(outreachDeg: number, bucklingDeg: number): number {
|
||||
const avg = (outreachDeg + bucklingDeg) / 2;
|
||||
return clamp(arrotonda1((avg / 180) * 100));
|
||||
}
|
||||
|
||||
/** Decrescente: meno cadute = meglio. Come `score_flamingo`. */
|
||||
export function scoreFlamingo(cadute: number): number {
|
||||
if (cadute <= 3) return 100;
|
||||
if (cadute <= 7) return arrotonda1(lerp(cadute, 3, 7, 100, 80));
|
||||
if (cadute <= 15) return arrotonda1(lerp(cadute, 7, 15, 80, 50));
|
||||
return clamp(arrotonda1(lerp(cadute, 15, 30, 50, 10)));
|
||||
}
|
||||
|
||||
/** Come `score_sit_and_reach`. */
|
||||
export function scoreSitAndReach(cm: number, sesso: Sesso): number {
|
||||
const median = eMaschio(sesso) ? 24 : 31;
|
||||
return clamp(arrotonda1(lerp(cm, median - 20, median + 10, 20, 100)));
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { apriSessione, registraMisure } from './misure';
|
||||
import { esisteTest } from './registro';
|
||||
|
||||
/** Versione del set di domande. Si incrementa quando cambia una domanda. */
|
||||
export const QUEST_VERSION = 'v1.0';
|
||||
|
||||
/** Versione del TESTO del consenso al trattamento dei dati sanitari — distinta da
|
||||
* QUEST_VERSION apposta: se cambia la formulazione del consenso senza che cambi una
|
||||
* domanda del questionario, il record del consenso deve poterlo dire. Prima di questa
|
||||
* costante il record portava QUEST_VERSION, la versione sbagliata. */
|
||||
export const CONSENT_VERSION = 'v1.0';
|
||||
|
||||
/**
|
||||
* Campi testuali che si salvano ma non entrano MAI in una formula
|
||||
* (indicazione esplicita del cliente, punto 7 della specifica).
|
||||
*/
|
||||
export const CAMPI_LIBERI = ['farmaci', 'problematiche_attuali', 'problematiche_pregresse', 'obiettivi'];
|
||||
|
||||
export function salvaCompilazione(
|
||||
db: Database.Database,
|
||||
input: {
|
||||
client_code: string; data: string; eta?: number;
|
||||
risposte: Record<string, number>;
|
||||
liberi?: Record<string, string>;
|
||||
}
|
||||
): number {
|
||||
// Validare PRIMA della transazione: se un test_id è sconosciuto, non apriamo
|
||||
// nemmeno una sessione. La validazione non è nel corpo della transazione.
|
||||
for (const id of Object.keys(input.risposte)) {
|
||||
if (!esisteTest(db, id)) throw new Error(`test_id non nel registro: ${id}`);
|
||||
}
|
||||
|
||||
// Avvolgi le tre scritture (apertura sessione, registra misure, inserisci note)
|
||||
// in un'unica transazione: tutto o niente, senza tracce parziali su dati sanitari.
|
||||
const tx = db.transaction(() => {
|
||||
const sessioneId = apriSessione(db, {
|
||||
client_code: input.client_code,
|
||||
data: input.data,
|
||||
tipo: 'questionario',
|
||||
eta_alla_data: input.eta,
|
||||
quest_version: QUEST_VERSION,
|
||||
});
|
||||
|
||||
registraMisure(
|
||||
db, sessioneId, 'questionario',
|
||||
Object.entries(input.risposte).map(([test_id, valore_num]) => ({ test_id, valore_num }))
|
||||
);
|
||||
|
||||
if (input.liberi) {
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
for (const [campo, testo] of Object.entries(input.liberi)) {
|
||||
if (testo.trim() === '') continue;
|
||||
ins.run(input.client_code, sessioneId, campo, testo);
|
||||
}
|
||||
}
|
||||
|
||||
return sessioneId;
|
||||
});
|
||||
|
||||
return tx();
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep';
|
||||
|
||||
/** Versione del modello di calcolo: curve e pesi. Distinta da QUEST_VERSION, che versiona le domande. */
|
||||
export const MODEL_VERSION = 'v1.0';
|
||||
|
||||
export type VoceRegistro = {
|
||||
test_id: string;
|
||||
etichetta: string;
|
||||
unita?: string;
|
||||
tipo_valore: 'num' | 'txt';
|
||||
curva?: Curva;
|
||||
params?: unknown;
|
||||
asse?: string;
|
||||
sotto_dominio?: string;
|
||||
range_min?: number;
|
||||
range_max?: number;
|
||||
attivo_da: string;
|
||||
attivo_a?: string;
|
||||
};
|
||||
|
||||
const DA = '2026-01-01';
|
||||
|
||||
// I 20 campi punteggiati, presi dal prototipo del cliente (questionario_longevity_score.html).
|
||||
// Gli id portano il prefisso q_ per distinguerli dai test fisici; i params sono nella forma
|
||||
// dichiarata per ciascuna curva, non nell'array posizionale del prototipo.
|
||||
const QUESTIONARIO: VoceRegistro[] = [
|
||||
{ test_id: 'q_ore_sonno', etichetta: 'Ore di sonno per notte (media)', unita: 'h', tipo_valore: 'num',
|
||||
curva: 'bell', params: { low: 4, peakLow: 7, peakHigh: 9, high: 12 },
|
||||
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
|
||||
{ test_id: 'q_min_addorm', etichetta: 'Minuti per addormentarti', unita: 'min', tipo_valore: 'num',
|
||||
curva: 'decstep', params: { steps: [[15, 100], [30, 75], [60, 50], [999, 25]] },
|
||||
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 180, attivo_da: DA },
|
||||
{ test_id: 'q_risvegli', etichetta: 'Risvegli notturni (numero)', tipo_valore: 'num',
|
||||
curva: 'decstep', params: { steps: [[0, 100], [1, 80], [2, 50], [999, 25]] },
|
||||
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 10, attivo_da: DA },
|
||||
{ test_id: 'q_riposato', etichetta: 'Quanto ti senti riposato al risveglio', tipo_valore: 'num',
|
||||
curva: 'x10', params: {}, asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno',
|
||||
range_min: 0, range_max: 10, attivo_da: DA },
|
||||
{ test_id: 'q_caffeina', etichetta: 'Caffeina dopo le 16:00 (volte/settimana)', tipo_valore: 'num',
|
||||
curva: 'lin_dec', params: { best: 0, worst: 7 },
|
||||
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
|
||||
{ test_id: 'q_sonnolenza_diurna', etichetta: 'Sonnolenza/fatica a concentrarti durante il giorno (volte/settimana)',
|
||||
tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 7 },
|
||||
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
|
||||
|
||||
{ test_id: 'q_energia_media', etichetta: 'Energia media nella giornata, ultima settimana', tipo_valore: 'num',
|
||||
curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress',
|
||||
range_min: 0, range_max: 10, attivo_da: DA },
|
||||
{ test_id: 'q_esaurimento', etichetta: 'Episodi di esaurimento senza motivo fisico (volte/sett.)', tipo_valore: 'num',
|
||||
curva: 'lin_dec', params: { best: 0, worst: 7 },
|
||||
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA },
|
||||
{ test_id: 'q_calo_pomeridiano', etichetta: 'Quanto forte e il calo di energia dal mattino al pomeriggio',
|
||||
tipo_valore: 'num', curva: 'x10_inv', params: {},
|
||||
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA },
|
||||
|
||||
{ test_id: 'q_sopraffatto', etichetta: 'Sopraffatto da imprevisti (volte/mese)', tipo_valore: 'num',
|
||||
curva: 'decstep', params: { steps: [[2, 100], [5, 70], [10, 40], [999, 20]] },
|
||||
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 30, attivo_da: DA },
|
||||
{ test_id: 'q_controllo', etichetta: 'Percezione di controllo sulla tua vita', tipo_valore: 'num',
|
||||
curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress',
|
||||
range_min: 0, range_max: 10, attivo_da: DA },
|
||||
{ test_id: 'q_sicurezza_gestione', etichetta: 'Ti senti sicuro/a nella tua capacita di gestire i problemi personali',
|
||||
tipo_valore: 'num', curva: 'x10', params: {},
|
||||
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA },
|
||||
{ test_id: 'q_tensione', etichetta: 'Tensione/irritabilita (volte/settimana)', tipo_valore: 'num',
|
||||
curva: 'lin_dec', params: { best: 0, worst: 7 },
|
||||
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA },
|
||||
{ test_id: 'q_pensieri_lavoro', etichetta: 'Ore/giorno di pensieri di lavoro fuori orario', unita: 'h',
|
||||
tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 4 },
|
||||
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 8, attivo_da: DA },
|
||||
|
||||
{ test_id: 'q_attivita', etichetta: 'Giorni/settimana attivita fisica extra ISL', tipo_valore: 'num',
|
||||
curva: 'incstep', params: { steps: [[0, 20], [2, 50], [4, 80], [999, 100]] },
|
||||
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 7, attivo_da: DA },
|
||||
{ test_id: 'q_alimentazione', etichetta: 'Qualita percepita alimentazione abituale', tipo_valore: 'num',
|
||||
curva: 'x10', params: {}, asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle',
|
||||
range_min: 0, range_max: 10, attivo_da: DA },
|
||||
{ test_id: 'q_sigarette', etichetta: 'Sigarette al giorno', tipo_valore: 'num',
|
||||
curva: 'decstep', params: { steps: [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]] },
|
||||
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 40, attivo_da: DA },
|
||||
// Soglia 0-7 = punteggio pieno: scelta deliberata del cliente (basso rischio NIAAA/WHO),
|
||||
// confermata il 21/08. Non e' un refuso per 0=100 -> 7=0.
|
||||
{ test_id: 'q_alcol_life', etichetta: 'Unita alcoliche/settimana', tipo_valore: 'num',
|
||||
curva: 'lin_dec', params: { best: 7, worst: 14 },
|
||||
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 30, attivo_da: DA },
|
||||
{ test_id: 'q_luce', etichetta: 'Ore/giorno luce naturale/esterno', unita: 'h', tipo_valore: 'num',
|
||||
curva: 'incstep', params: { steps: [[0.5, 60], [1, 90], [999, 100]], zeroVal: 20 },
|
||||
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 8, attivo_da: DA },
|
||||
{ test_id: 'q_schermi', etichetta: 'Minuti schermi nell\'ora pre-sonno', unita: 'min', tipo_valore: 'num',
|
||||
curva: 'decstep', params: { steps: [[15, 85], [30, 70], [60, 40], [999, 10]], zeroVal: 100 },
|
||||
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA },
|
||||
];
|
||||
|
||||
export function seedRegistro(db: Database.Database): void {
|
||||
const ins = db.prepare(
|
||||
`INSERT OR REPLACE INTO registro_test
|
||||
(test_id, etichetta, unita, tipo_valore, curva, params, asse, sotto_dominio,
|
||||
range_min, range_max, attivo_da, attivo_a, note)
|
||||
VALUES (@test_id, @etichetta, @unita, @tipo_valore, @curva, @params, @asse, @sotto_dominio,
|
||||
@range_min, @range_max, @attivo_da, @attivo_a, @note)`
|
||||
);
|
||||
const tx = db.transaction((voci: VoceRegistro[]) => {
|
||||
for (const v of voci) {
|
||||
ins.run({
|
||||
test_id: v.test_id, etichetta: v.etichetta, unita: v.unita ?? null,
|
||||
tipo_valore: v.tipo_valore, curva: v.curva ?? null,
|
||||
params: v.params === undefined ? null : JSON.stringify(v.params),
|
||||
asse: v.asse ?? null, sotto_dominio: v.sotto_dominio ?? null,
|
||||
range_min: v.range_min ?? null, range_max: v.range_max ?? null,
|
||||
attivo_da: v.attivo_da, attivo_a: v.attivo_a ?? null, note: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
tx(QUESTIONARIO);
|
||||
}
|
||||
|
||||
// Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS,
|
||||
// FITNESS_AGE_WEIGHTS). I macro con peso 0 non si inseriscono: assenza e zero sono
|
||||
// la stessa cosa per la rinormalizzazione, e una riga a zero confonde chi legge.
|
||||
// ⚠️ Il plank compare volutamente in DUE assi: 'core' in Forza & Struttura (0.15) e
|
||||
// 'plank' in Stabilità & Mobilità (0.20). Confermato da Nicola il 22/08 e motivato:
|
||||
// il plank non è forza pura, richiede controllo neuromuscolare del tronco, quindi
|
||||
// contribuisce davvero a entrambi i domini che misura. Non è un refuso da correggere.
|
||||
const PESI_ASSE: Record<string, Record<string, number>> = {
|
||||
'Forza & Struttura': { handgrip: 0.25, spinta: 0.20, trazione: 0.20, arti_inferiori: 0.20, core: 0.15 },
|
||||
'Composizione Corporea': { grasso: 0.40, muscolo: 0.35, whr: 0.25 },
|
||||
'Cardio-Respiratorio': { vo2max: 0.60, spirometria: 0.20, wellness_tower_cardio: 0.20 },
|
||||
'Recupero & Sistema Nervoso': { hrv: 0.50, pressione: 0.15, hrr: 0.15, questionario_sonno: 0.20 },
|
||||
'Energia & Regolazione Stress': { hrv: 0.50, questionario_energia_stress: 0.50 },
|
||||
'Stabilità & Mobilità Funzionale': { flamingo: 0.35, sit_and_reach: 0.25, plank: 0.20, back_scratch: 0.14, wellness_tower_shoulder: 0.06 },
|
||||
'Stile di Vita & Sonno': { questionario_lifestyle: 1.00 },
|
||||
};
|
||||
|
||||
const PESI_MACRO: Record<string, Record<string, number>> = {
|
||||
PERFORMANCE: {
|
||||
'Forza & Struttura': 0.25, 'Cardio-Respiratorio': 0.25, 'Composizione Corporea': 0.15,
|
||||
'Stabilità & Mobilità Funzionale': 0.20, 'Recupero & Sistema Nervoso': 0.10,
|
||||
'Energia & Regolazione Stress': 0.05,
|
||||
},
|
||||
ENERGY: {
|
||||
'Energia & Regolazione Stress': 0.30, 'Stile di Vita & Sonno': 0.20,
|
||||
'Recupero & Sistema Nervoso': 0.25, 'Cardio-Respiratorio': 0.15, 'Composizione Corporea': 0.10,
|
||||
},
|
||||
RECOVERY: {
|
||||
'Recupero & Sistema Nervoso': 0.45, 'Energia & Regolazione Stress': 0.15,
|
||||
'Stile di Vita & Sonno': 0.20, 'Stabilità & Mobilità Funzionale': 0.15, 'Forza & Struttura': 0.05,
|
||||
},
|
||||
};
|
||||
|
||||
const PESI_FITNESS_AGE: Record<string, number> = {
|
||||
cardio: 0.30, handgrip_isolato: 0.20, hrv_isolato: 0.20,
|
||||
forza_resto: 0.15, composizione: 0.10, stabilita: 0.05,
|
||||
};
|
||||
|
||||
export function seedPesi(db: Database.Database, modelVersion: string): void {
|
||||
const ins = db.prepare(
|
||||
`INSERT OR REPLACE INTO pesi (model_version, livello, contenitore, elemento, peso)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
);
|
||||
const tx = db.transaction(() => {
|
||||
for (const [asse, sd] of Object.entries(PESI_ASSE))
|
||||
for (const [el, p] of Object.entries(sd)) ins.run(modelVersion, 'asse', asse, el, p);
|
||||
for (const [macro, assi] of Object.entries(PESI_MACRO))
|
||||
for (const [el, p] of Object.entries(assi)) ins.run(modelVersion, 'macro', macro, el, p);
|
||||
for (const [el, p] of Object.entries(PESI_FITNESS_AGE))
|
||||
ins.run(modelVersion, 'fitness_age', 'fitness_age', el, p);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
export function testAttivi(db: Database.Database, alla: string): VoceRegistro[] {
|
||||
const righe = db.prepare(
|
||||
`SELECT * FROM registro_test
|
||||
WHERE attivo_da <= ? AND (attivo_a IS NULL OR attivo_a > ?)
|
||||
ORDER BY test_id`
|
||||
).all(alla, alla) as Record<string, unknown>[];
|
||||
return righe.map((r) => ({
|
||||
...r,
|
||||
params: r.params ? JSON.parse(r.params as string) : undefined,
|
||||
})) as VoceRegistro[];
|
||||
}
|
||||
|
||||
export function esisteTest(db: Database.Database, testId: string): boolean {
|
||||
const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId);
|
||||
return r !== undefined;
|
||||
}
|
||||
|
||||
/** I pesi di un contenitore (un asse, un macro-score o la Fitness Age) per la versione del modello data. */
|
||||
export function pesiDi(
|
||||
db: Database.Database, modelVersion: string,
|
||||
livello: 'asse' | 'macro' | 'fitness_age', contenitore: string
|
||||
): Record<string, number> {
|
||||
const righe = db.prepare(
|
||||
`SELECT elemento, peso FROM pesi WHERE model_version = ? AND livello = ? AND contenitore = ?`
|
||||
).all(modelVersion, livello, contenitore) as { elemento: string; peso: number }[];
|
||||
return Object.fromEntries(righe.map((r) => [r.elemento, r.peso]));
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
//
|
||||
// La vista che il questionario in pagina usa. Due responsabilità:
|
||||
// - campiDelQuestionario: i campi del modulo, letti dal registro (mai una lista nel
|
||||
// codice) — aggiungere o disattivare una domanda resta una modifica ai dati.
|
||||
// - salvaDalForm: il salvataggio, con la sola regola che conta davvero — il consenso al
|
||||
// trattamento dei dati sanitari va DATO, non solo compilato. Il prototipo del cliente
|
||||
// salvava anche con "No" perché controllava solo che il campo non fosse vuoto: qui il
|
||||
// consenso si verifica PRIMA di scrivere qualsiasi altra cosa, e un rifiuto non apre
|
||||
// nessuna sessione. Il rifiuto stesso si registra comunque: è un fatto (chi, quando, a
|
||||
// cosa) che un domani va dimostrato tanto quanto un assenso.
|
||||
import type Database from 'better-sqlite3';
|
||||
import { testAttivi, pesiDi, MODEL_VERSION } from './registro';
|
||||
import { salvaCompilazione, CONSENT_VERSION } from './questionario';
|
||||
import { leggiScore, calcolaSessione, salvaScore } from './motore';
|
||||
|
||||
export type CampoQuestionario = {
|
||||
id: string;
|
||||
etichetta: string;
|
||||
tipo: 'num' | 'txt';
|
||||
unita?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
sottoDominio?: string;
|
||||
};
|
||||
|
||||
/** I campi del modulo alla data indicata: solo i test_id del questionario (prefisso
|
||||
* `q_`, per non confonderli con quelli dei test fisici), attivi a quella data. */
|
||||
export function campiDelQuestionario(db: Database.Database, alla: string): CampoQuestionario[] {
|
||||
return testAttivi(db, alla)
|
||||
.filter((v) => v.test_id.startsWith('q_'))
|
||||
.map((v) => ({
|
||||
id: v.test_id,
|
||||
etichetta: v.etichetta,
|
||||
tipo: v.tipo_valore,
|
||||
unita: v.unita,
|
||||
min: v.range_min,
|
||||
max: v.range_max,
|
||||
sottoDominio: v.sotto_dominio,
|
||||
}));
|
||||
}
|
||||
|
||||
export type EsitoSalvataggio =
|
||||
| { ok: true; sessioneId: number }
|
||||
| { ok: false; errore: string };
|
||||
|
||||
const CONSENSI_AFFERMATIVI = new Set(['si', 'sì']);
|
||||
|
||||
function consensoDato(risposta: string): boolean {
|
||||
return CONSENSI_AFFERMATIVI.has(risposta.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/** Registra il fatto del consenso — chi, quando, a che versione del TESTO del consenso
|
||||
* (CONSENT_VERSION, non QUEST_VERSION: il consenso può cambiare formulazione senza che
|
||||
* cambi una domanda) — indipendentemente dall'esito. Legata al cliente sempre; alla
|
||||
* sessione solo quando il consenso è affermativo e la sessione esiste davvero. */
|
||||
function registraConsenso(
|
||||
db: Database.Database, clientCode: string, sessioneId: number | null, risposta: string
|
||||
): void {
|
||||
const esito = risposta.trim() === '' ? '(non risposto)' : risposta.trim();
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
const testo = `Consenso al trattamento dei dati sanitari: ${esito} — consenso ${CONSENT_VERSION}, registrato il ${oggi}`;
|
||||
db.prepare(
|
||||
`INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)`
|
||||
).run(clientCode, sessioneId, 'consenso_sanitario', testo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Il salvataggio dal form del questionario. Il consenso si controlla PRIMA di scrivere
|
||||
* qualunque altra cosa: se non è affermativo, non si apre nessuna sessione — né misure né
|
||||
* note di profilo — e la compilazione non entra nel fascicolo. La nota del consenso si
|
||||
* scrive comunque, con l'esito letterale ricevuto.
|
||||
*
|
||||
* Quando il consenso è affermativo, TUTTO il resto avviene in un'unica transazione (il
|
||||
* salvataggio dei dati sanitari, il calcolo dei punteggi e il congelamento in `score`, la
|
||||
* nota di consenso): o entra tutto nel fascicolo, o non entra niente. Prima di questa
|
||||
* correzione il calcolo restava scollegato dal salvataggio — nessuno chiamava
|
||||
* calcolaSessione/salvaScore in produzione — e la nota di consenso si scriveva fuori dalla
|
||||
* transazione, quindi un fallimento lì poteva lasciare dati sanitari senza il consenso che
|
||||
* li giustifica. `db.transaction` di better-sqlite3 annida automaticamente con savepoint
|
||||
* quelle interne di `salvaCompilazione` e `salvaScore`.
|
||||
*/
|
||||
export function salvaDalForm(
|
||||
db: Database.Database,
|
||||
input: {
|
||||
client_code: string; data: string; eta?: number;
|
||||
risposte: Record<string, number>;
|
||||
consensoSanitario: string;
|
||||
liberi?: Record<string, string>;
|
||||
}
|
||||
): EsitoSalvataggio {
|
||||
if (!consensoDato(input.consensoSanitario)) {
|
||||
registraConsenso(db, input.client_code, null, input.consensoSanitario);
|
||||
return {
|
||||
ok: false,
|
||||
errore: 'Senza consenso al trattamento dei dati sanitari la compilazione non viene salvata.',
|
||||
};
|
||||
}
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
const sessioneId = salvaCompilazione(db, {
|
||||
client_code: input.client_code,
|
||||
data: input.data,
|
||||
eta: input.eta,
|
||||
risposte: input.risposte,
|
||||
liberi: input.liberi,
|
||||
});
|
||||
|
||||
// Il calcolo e il congelamento dei punteggi sono parte del salvataggio, non un passo
|
||||
// successivo affidato a chi legge: senza questo, una compilazione restava per sempre
|
||||
// senza i suoi punteggi.
|
||||
salvaScore(db, sessioneId, calcolaSessione(db, sessioneId));
|
||||
|
||||
registraConsenso(db, input.client_code, sessioneId, input.consensoSanitario);
|
||||
|
||||
return sessioneId;
|
||||
});
|
||||
|
||||
return { ok: true, sessioneId: tx() };
|
||||
}
|
||||
|
||||
//
|
||||
// Il referto che il cliente legge: il radar a sette assi e le carte dei macro-score.
|
||||
// Due regole non negoziabili, entrambe già chiuse a monte, non da questo modulo:
|
||||
// - il valore viene da `leggiScore` (punteggi congelati in `score`), mai da un
|
||||
// ricalcolo — cambiare i pesi non deve mai cambiare un referto già emesso;
|
||||
// - un asse/macro insufficiente non porta un `valore` (resta `null`): è il tipo
|
||||
// `Punteggio` del motore che lo rende impossibile da violare per distrazione.
|
||||
//
|
||||
|
||||
/** Un asse del radar: il valore (assente se il dato non basta), la copertura di peso
|
||||
* disponibile e — solo quando manca — l'elenco leggibile di cosa lo completerebbe. */
|
||||
export type AsseVista = { nome: string; valore: number | null; copertura: number; mancano: string[] };
|
||||
|
||||
/** Una carta di macro-score: stesso principio degli assi, senza l'elenco di cosa manca
|
||||
* (il "cosa manca" si legge scendendo ai sette assi, non qui). */
|
||||
export type MacroVista = { nome: string; valore: number | null; copertura: number };
|
||||
|
||||
/**
|
||||
* Le etichette leggibili dei sotto-domini che alimentano un asse: le chiavi sono quelle
|
||||
* di `PESI_ASSE` in registro.ts. Non tutte hanno oggi una riga in `registro_test` — i
|
||||
* test fisici (handgrip, VO2max, plank...) arrivano con un piano successivo, il
|
||||
* questionario è l'unica fonte di misure che esiste davvero — quindi la traduzione non
|
||||
* può venire da lì: è dato locale a questo modulo, non duplica il registro, lo completa
|
||||
* dove il registro non arriva ancora.
|
||||
*/
|
||||
const ETICHETTE_SOTTO_DOMINIO: Record<string, string> = {
|
||||
handgrip: 'Forza di presa (handgrip)',
|
||||
spinta: 'Test di spinta (push-up o panca)',
|
||||
trazione: 'Test di trazione',
|
||||
arti_inferiori: 'Test arti inferiori',
|
||||
core: 'Test del core (plank)',
|
||||
grasso: 'Composizione corporea — massa grassa',
|
||||
muscolo: 'Composizione corporea — massa muscolare',
|
||||
whr: 'Rapporto vita-fianchi (WHR)',
|
||||
vo2max: 'Test cardio (VO2max)',
|
||||
spirometria: 'Spirometria',
|
||||
wellness_tower_cardio: 'Wellness Tower — cardio',
|
||||
hrv: 'Variabilità della frequenza cardiaca (HRV)',
|
||||
pressione: 'Pressione arteriosa',
|
||||
hrr: 'Frequenza cardiaca di recupero (HRR)',
|
||||
questionario_sonno: 'Questionario del sonno',
|
||||
questionario_energia_stress: 'Questionario energia e stress',
|
||||
flamingo: 'Test di equilibrio (flamingo)',
|
||||
sit_and_reach: 'Test di flessibilità (sit and reach)',
|
||||
plank: 'Plank (tenuta)',
|
||||
back_scratch: 'Test di mobilità delle spalle (back scratch)',
|
||||
wellness_tower_shoulder: 'Wellness Tower — spalla',
|
||||
questionario_lifestyle: 'Questionario di stile di vita',
|
||||
};
|
||||
|
||||
/** I contenitori (assi o macro-score) dichiarati nei pesi per il model version corrente,
|
||||
* nell'ordine in cui compaiono nella tabella — che è l'ordine di inserimento di `seedPesi`,
|
||||
* cioè quello dichiarato in registro.ts. Non un elenco duplicato qui: si legge dal dato. */
|
||||
function contenitoriDi(db: Database.Database, livello: 'asse' | 'macro'): string[] {
|
||||
return (db.prepare(
|
||||
`SELECT contenitore FROM pesi WHERE model_version = ? AND livello = ?
|
||||
GROUP BY contenitore ORDER BY MIN(rowid)`
|
||||
).all(MODEL_VERSION, livello) as { contenitore: string }[]).map((r) => r.contenitore);
|
||||
}
|
||||
|
||||
/** I sotto-domini con almeno una misura valida (nel range atteso) in questa sessione:
|
||||
* l'informazione grezza da cui `mancano` si costruisce, non un punteggio — leggere quali
|
||||
* test sono stati fatti non è ricalcolare come pesano. */
|
||||
function sottoDominiPresenti(db: Database.Database, sessioneId: number): Set<string> {
|
||||
const righe = db.prepare(
|
||||
`SELECT DISTINCT rt.sotto_dominio AS sd
|
||||
FROM misure m JOIN registro_test rt ON rt.test_id = m.test_id
|
||||
WHERE m.sessione_id = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL
|
||||
AND rt.sotto_dominio IS NOT NULL`
|
||||
).all(sessioneId) as { sd: string }[];
|
||||
return new Set(righe.map((r) => r.sd));
|
||||
}
|
||||
|
||||
/** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse
|
||||
* che questa sessione non ha, tradotti nell'etichetta leggibile. */
|
||||
function mancanoPer(db: Database.Database, sessioneId: number, nomeAsse: string, presenti: Set<string>): string[] {
|
||||
const pesi = pesiDi(db, MODEL_VERSION, 'asse', nomeAsse);
|
||||
return Object.keys(pesi)
|
||||
.filter((sd) => !presenti.has(sd))
|
||||
.map((sd) => ETICHETTE_SOTTO_DOMINIO[sd] ?? sd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Il referto di una sessione: i sette assi del radar, i macro-score e la Fitness Age.
|
||||
* Legge SOLO `leggiScore` per i valori (punteggi congelati): non chiama mai
|
||||
* `calcolaSessione`, quindi un peso cambiato dopo il fatto non altera un referto già
|
||||
* emesso. `mancano` guarda invece le misure grezze della sessione (quali test ci sono),
|
||||
* non il punteggio — è informazione su cosa manca, non un ricalcolo di quanto vale.
|
||||
*/
|
||||
export function refertoDi(
|
||||
db: Database.Database,
|
||||
sessioneId: number
|
||||
): { assi: AsseVista[]; macro: MacroVista[]; fitnessAge: number | null; fitnessAgeCopertura: number } {
|
||||
const punteggi = leggiScore(db, sessioneId);
|
||||
const presenti = sottoDominiPresenti(db, sessioneId);
|
||||
|
||||
const assi: AsseVista[] = contenitoriDi(db, 'asse').map((nome) => {
|
||||
const p = punteggi.assi[nome];
|
||||
if (p?.stato === 'ok') return { nome, valore: p.valore, copertura: p.copertura, mancano: [] };
|
||||
return {
|
||||
nome, valore: null, copertura: p?.copertura ?? 0,
|
||||
mancano: mancanoPer(db, sessioneId, nome, presenti),
|
||||
};
|
||||
});
|
||||
|
||||
const macro: MacroVista[] = contenitoriDi(db, 'macro').map((nome) => {
|
||||
const p = punteggi.macro[nome];
|
||||
return p?.stato === 'ok'
|
||||
? { nome, valore: p.valore, copertura: p.copertura }
|
||||
: { nome, valore: null, copertura: p?.copertura ?? 0 };
|
||||
});
|
||||
|
||||
const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null;
|
||||
// Prima apertura: con poche misure la Fitness Age manca quasi sempre. La pagina deve
|
||||
// poter dire QUANTO manca (come fa già ogni carta di macro-score), non solo che manca:
|
||||
// la copertura resta leggibile anche quando lo stato è 'insufficiente'.
|
||||
const fitnessAgeCopertura = punteggi.fitnessAge?.copertura ?? 0;
|
||||
|
||||
return { assi, macro, fitnessAge, fitnessAgeCopertura };
|
||||
}
|
||||
|
||||
/** L'ultima sessione di un cliente (la più recente per data, a parità di data la più
|
||||
* recente per id). `null` se il cliente non ha ancora nessuna sessione — la pagina del
|
||||
* referto la usa per non dover interrogare `sessioni` da sola. */
|
||||
export function ultimaSessioneDi(db: Database.Database, clientCode: string): number | null {
|
||||
const r = db.prepare(
|
||||
`SELECT id FROM sessioni WHERE client_code = ? ORDER BY data DESC, id DESC LIMIT 1`
|
||||
).get(clientCode) as { id: number } | undefined;
|
||||
return r?.id ?? null;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere
|
||||
// protocol-relative ("//"), non puntare alle API. Tutto il resto → home.
|
||||
export function safeNext(next: string | null): string {
|
||||
if (next && next.includes('\\')) return '/';
|
||||
if (!next || !next.startsWith('/') || next.startsWith('//')) return '/';
|
||||
if (next.startsWith('/api/')) return '/';
|
||||
return next;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Calcoli della vista Analytics. Stanno fuori dal componente perché sono la parte con una
|
||||
// logica vera — e quindi l'unica che vale la pena testare — mentre la vista si limita a
|
||||
// disegnare ciò che esce da qui.
|
||||
import type { Alert, Client, Measurement } from './data';
|
||||
|
||||
export interface Periodo { from: string; to: string }
|
||||
|
||||
export const METRICHE = [
|
||||
{ key: 'stress', label: 'Stress', color: '#b05a4e' },
|
||||
{ key: 'recovery', label: 'Recupero', color: '#6f8f6a' },
|
||||
{ key: 'balance', label: 'Equilibrio', color: '#7d9b96' },
|
||||
{ key: 'energy', label: 'Energia', color: '#c08a3e' },
|
||||
] as const;
|
||||
|
||||
export const FASCE = ['0-20', '21-40', '41-60', '61-80', '81-100'];
|
||||
|
||||
/** Fascia di appartenenza di uno score 0-100. */
|
||||
export function fascia(value: number): string {
|
||||
if (value <= 20) return FASCE[0];
|
||||
if (value <= 40) return FASCE[1];
|
||||
if (value <= 60) return FASCE[2];
|
||||
if (value <= 80) return FASCE[3];
|
||||
return FASCE[4];
|
||||
}
|
||||
|
||||
const dentro = (date: string, p: Periodo) => date >= p.from && date <= p.to;
|
||||
|
||||
/** Misurazioni del periodo, cliente per cliente. */
|
||||
export function misurazioniNelPeriodo(
|
||||
byClient: Record<string, Measurement[]>,
|
||||
periodo: Periodo
|
||||
): Record<string, Measurement[]> {
|
||||
const out: Record<string, Measurement[]> = {};
|
||||
for (const [id, serie] of Object.entries(byClient)) {
|
||||
out[id] = serie.filter((m) => dentro(m.date, periodo));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Kpi { label: string; value: string | number; note: string }
|
||||
|
||||
export function kpis(
|
||||
clients: Client[],
|
||||
byClient: Record<string, Measurement[]>,
|
||||
alerts: Alert[],
|
||||
periodo: Periodo
|
||||
): Kpi[] {
|
||||
const nelPeriodo = misurazioniNelPeriodo(byClient, periodo);
|
||||
const attivi = clients.filter((c) => (nelPeriodo[c.id] ?? []).length > 0).length;
|
||||
const misurazioni = Object.values(nelPeriodo).reduce((n, s) => n + s.length, 0);
|
||||
const alertAttivi = alerts.filter((a) => dentro(a.when, periodo)).length;
|
||||
const aderenza = clients.length ? Math.round((attivi / clients.length) * 100) : 0;
|
||||
return [
|
||||
{ label: 'Clienti attivi', value: attivi, note: `/ ${clients.length} totali` },
|
||||
{ label: 'Misurazioni', value: misurazioni, note: 'nel periodo' },
|
||||
{ label: 'Alert attivi', value: alertAttivi, note: 'nel periodo' },
|
||||
{ label: 'Aderenza', value: `${aderenza}%`, note: 'hanno misurato' },
|
||||
];
|
||||
}
|
||||
|
||||
/** Quante misurazioni cadono in ogni fascia, separatamente per ciascuna metrica. */
|
||||
export function distribuzione(
|
||||
byClient: Record<string, Measurement[]>,
|
||||
periodo: Periodo
|
||||
): Record<string, number | string>[] {
|
||||
const righe = FASCE.map((f) => {
|
||||
const riga: Record<string, number | string> = { range: f };
|
||||
for (const m of METRICHE) riga[m.key] = 0;
|
||||
return riga;
|
||||
});
|
||||
for (const serie of Object.values(misurazioniNelPeriodo(byClient, periodo))) {
|
||||
for (const misura of serie) {
|
||||
for (const metrica of METRICHE) {
|
||||
const riga = righe[FASCE.indexOf(fascia(misura[metrica.key]))];
|
||||
riga[metrica.key] = (riga[metrica.key] as number) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return righe;
|
||||
}
|
||||
|
||||
export interface RigaClassifica { clientId: string; name: string; recovery: number; count: number }
|
||||
|
||||
/** Clienti ordinati per recupero medio nel periodo: i migliori in testa. */
|
||||
export function classificaRecupero(
|
||||
clients: Client[],
|
||||
byClient: Record<string, Measurement[]>,
|
||||
periodo: Periodo
|
||||
): RigaClassifica[] {
|
||||
const nelPeriodo = misurazioniNelPeriodo(byClient, periodo);
|
||||
return clients
|
||||
.map((c) => {
|
||||
const serie = nelPeriodo[c.id] ?? [];
|
||||
const media = serie.length ? serie.reduce((n, m) => n + m.recovery, 0) / serie.length : 0;
|
||||
return { clientId: c.id, name: c.name, recovery: Math.round(media), count: serie.length };
|
||||
})
|
||||
.filter((r) => r.count > 0)
|
||||
.sort((a, b) => b.recovery - a.recovery);
|
||||
}
|
||||
|
||||
export type Dimensione = 'sesso' | 'eta' | 'tag';
|
||||
|
||||
export const DIMENSIONI: { key: Dimensione; label: string }[] = [
|
||||
{ key: 'sesso', label: 'Sesso' },
|
||||
{ key: 'eta', label: 'Età' },
|
||||
{ key: 'tag', label: 'Tag principale' },
|
||||
];
|
||||
|
||||
function gruppoDi(client: Client, dimensione: Dimensione): string {
|
||||
if (dimensione === 'sesso') return client.sex === 'F' ? 'Donne' : 'Uomini';
|
||||
if (dimensione === 'eta') return client.age < 35 ? 'Fino a 34' : client.age < 50 ? '35-49' : '50 e oltre';
|
||||
return client.tags[0] ?? 'Senza tag';
|
||||
}
|
||||
|
||||
/** Stress e recupero medi per gruppo, secondo la dimensione scelta. */
|
||||
export function confrontoSegmenti(
|
||||
clients: Client[],
|
||||
byClient: Record<string, Measurement[]>,
|
||||
periodo: Periodo,
|
||||
dimensione: Dimensione
|
||||
): Record<string, number | string>[] {
|
||||
const nelPeriodo = misurazioniNelPeriodo(byClient, periodo);
|
||||
const gruppi = new Map<string, { stress: number; recovery: number; n: number }>();
|
||||
for (const client of clients) {
|
||||
const serie = nelPeriodo[client.id] ?? [];
|
||||
if (!serie.length) continue;
|
||||
const nome = gruppoDi(client, dimensione);
|
||||
const acc = gruppi.get(nome) ?? { stress: 0, recovery: 0, n: 0 };
|
||||
for (const m of serie) {
|
||||
acc.stress += m.stress;
|
||||
acc.recovery += m.recovery;
|
||||
acc.n += 1;
|
||||
}
|
||||
gruppi.set(nome, acc);
|
||||
}
|
||||
return [...gruppi.entries()].map(([gruppo, a]) => ({
|
||||
gruppo,
|
||||
stress: Math.round(a.stress / a.n),
|
||||
recovery: Math.round(a.recovery / a.n),
|
||||
}));
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
// Dati della piattaforma Stress Index. Sono ancora i dati dimostrativi del prototipo
|
||||
// (nessun backend HRV collegato): stanno tutti qui, così quando arriverà la sorgente vera
|
||||
// basterà sostituire queste costanti con le query, lasciando intatte pagine e componenti.
|
||||
|
||||
export interface Client {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
age: number;
|
||||
sex: 'M' | 'F';
|
||||
tags: string[];
|
||||
email: string;
|
||||
since: string;
|
||||
lastMeasurement: string;
|
||||
stress: number;
|
||||
alerts: number;
|
||||
}
|
||||
|
||||
export const clients: Client[] = [
|
||||
{ id: 'c1', name: 'L. Verdi', initials: 'LV', age: 34, sex: 'F', tags: ['ansia', 'sonno'], email: 'l.verdi@esempio.it', since: '2024-02-10', lastMeasurement: '2026-07-10', stress: 62, alerts: 1 },
|
||||
{ id: 'c2', name: 'A. Neri', initials: 'AN', age: 45, sex: 'M', tags: ['sport', 'recupero'], email: 'a.neri@esempio.it', since: '2023-11-05', lastMeasurement: '2026-07-11', stress: 38, alerts: 0 },
|
||||
{ id: 'c3', name: 'G. Rossi', initials: 'GR', age: 29, sex: 'M', tags: ['lavoro', 'stress'], email: 'g.rossi@esempio.it', since: '2024-05-20', lastMeasurement: '2026-07-09', stress: 78, alerts: 2 },
|
||||
{ id: 'c4', name: 'S. Ferrari', initials: 'SF', age: 52, sex: 'F', tags: ['menopausa', 'sonno'], email: 's.ferrari@esempio.it', since: '2023-08-14', lastMeasurement: '2026-07-12', stress: 55, alerts: 0 },
|
||||
{ id: 'c5', name: 'P. Colombo', initials: 'PC', age: 40, sex: 'M', tags: ['sport', 'agonismo'], email: 'p.colombo@esempio.it', since: '2024-01-30', lastMeasurement: '2026-07-08', stress: 44, alerts: 0 },
|
||||
{ id: 'c6', name: 'E. Ricci', initials: 'ER', age: 61, sex: 'F', tags: ['cronico', 'riabilitazione'], email: 'e.ricci@esempio.it', since: '2022-09-01', lastMeasurement: '2026-07-11', stress: 70, alerts: 1 },
|
||||
{ id: 'c7', name: 'D. Marino', initials: 'DM', age: 33, sex: 'M', tags: ['lavoro', 'burnout'], email: 'd.marino@esempio.it', since: '2024-03-18', lastMeasurement: '2026-07-07', stress: 82, alerts: 2 },
|
||||
{ id: 'c8', name: 'C. Greco', initials: 'CG', age: 27, sex: 'F', tags: ['sport', 'studentessa'], email: 'c.greco@esempio.it', since: '2024-06-02', lastMeasurement: '2026-07-12', stress: 30, alerts: 0 },
|
||||
{ id: 'c9', name: 'F. Bruno', initials: 'FB', age: 48, sex: 'M', tags: ['ipertensione', 'sonno'], email: 'f.bruno@esempio.it', since: '2023-04-25', lastMeasurement: '2026-07-06', stress: 66, alerts: 1 },
|
||||
{ id: 'c10', name: 'V. Costa', initials: 'VC', age: 36, sex: 'F', tags: ['ansia', 'lavoro'], email: 'v.costa@esempio.it', since: '2024-07-01', lastMeasurement: '2026-07-10', stress: 58, alerts: 1 },
|
||||
];
|
||||
|
||||
export type Measurement = {
|
||||
date: string;
|
||||
stress: number;
|
||||
recovery: number;
|
||||
balance: number;
|
||||
energy: number;
|
||||
type: string;
|
||||
durationMin: number;
|
||||
}
|
||||
|
||||
const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n)));
|
||||
const TYPES = ['Riposo', 'Lavoro', 'Sport', 'Sonno'];
|
||||
|
||||
// Serie settimanale deterministica: l'oscillazione deriva da id + indice, così i grafici
|
||||
// sono sempre uguali fra server e client (nessun Math.random che sfarfalla all'idratazione).
|
||||
function buildSeries(clientId: string, baseStress: number, endDate: string, points: number): Measurement[] {
|
||||
const end = new Date(endDate);
|
||||
const series: Measurement[] = [];
|
||||
for (let i = points - 1; i >= 0; i--) {
|
||||
const d = new Date(end);
|
||||
d.setDate(d.getDate() - i * 7);
|
||||
const seed = clientId.charCodeAt(clientId.length - 1) + i * 13;
|
||||
const wobble = Math.sin(seed) * 12;
|
||||
const stress = clamp(baseStress + wobble);
|
||||
series.push({
|
||||
date: d.toISOString().slice(0, 10),
|
||||
stress,
|
||||
recovery: clamp(100 - stress + Math.cos(seed) * 8),
|
||||
balance: clamp(70 - Math.abs(wobble) + Math.sin(seed * 2) * 6),
|
||||
energy: clamp(65 - (stress - 50) * 0.4 + Math.cos(seed * 1.5) * 7),
|
||||
type: TYPES[i % TYPES.length],
|
||||
durationMin: 15 + ((i * 5) % 40),
|
||||
});
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
export const measurementsByClient: Record<string, Measurement[]> = {
|
||||
c1: buildSeries('c1', 62, '2026-07-10', 16),
|
||||
c2: buildSeries('c2', 38, '2026-07-11', 14),
|
||||
c3: buildSeries('c3', 78, '2026-07-09', 18),
|
||||
c4: buildSeries('c4', 55, '2026-07-12', 12),
|
||||
c5: buildSeries('c5', 44, '2026-07-08', 15),
|
||||
c6: buildSeries('c6', 70, '2026-07-11', 20),
|
||||
c7: buildSeries('c7', 82, '2026-07-07', 17),
|
||||
c8: buildSeries('c8', 30, '2026-07-12', 10),
|
||||
c9: buildSeries('c9', 66, '2026-07-06', 13),
|
||||
c10: buildSeries('c10', 58, '2026-07-10', 11),
|
||||
};
|
||||
|
||||
export interface Alert {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
text: string;
|
||||
severity: 'warn' | 'error';
|
||||
when: string;
|
||||
}
|
||||
|
||||
export const alerts: Alert[] = [
|
||||
{ clientId: 'c7', clientName: 'D. Marino', text: 'Stress elevato per 3 misurazioni consecutive', severity: 'error', when: '2026-07-07' },
|
||||
{ clientId: 'c3', clientName: 'G. Rossi', text: 'Recupero notturno in calo', severity: 'warn', when: '2026-07-09' },
|
||||
{ clientId: 'c3', clientName: 'G. Rossi', text: 'Bilancio simpatico/parasimpatico sbilanciato', severity: 'warn', when: '2026-07-05' },
|
||||
{ clientId: 'c1', clientName: 'L. Verdi', text: 'Qualità del sonno sotto la soglia attesa', severity: 'warn', when: '2026-07-10' },
|
||||
{ clientId: 'c6', clientName: 'E. Ricci', text: 'Andamento stress in peggioramento nelle ultime 4 settimane', severity: 'warn', when: '2026-07-11' },
|
||||
{ clientId: 'c9', clientName: 'F. Bruno', text: 'Nessuna misurazione da oltre 7 giorni', severity: 'warn', when: '2026-07-13' },
|
||||
{ clientId: 'c10', clientName: 'V. Costa', text: 'Picco di stress rilevato dopo sessione di lavoro prolungata', severity: 'warn', when: '2026-07-10' },
|
||||
{ clientId: 'c7', clientName: 'D. Marino', text: 'Energia media sotto il 30% da due settimane', severity: 'error', when: '2026-07-01' },
|
||||
];
|
||||
|
||||
export interface Note {
|
||||
clientId?: string;
|
||||
text: string;
|
||||
when: string;
|
||||
}
|
||||
|
||||
export const notes: Note[] = [
|
||||
{ clientId: 'c1', text: 'Consigliata routine di respirazione serale, rivalutare tra 2 settimane.', when: '2026-07-10' },
|
||||
{ clientId: 'c3', text: 'Segnalato carico lavorativo elevato, proposto colloquio di follow-up.', when: '2026-07-09' },
|
||||
{ clientId: 'c7', text: 'Situazione di burnout in peggioramento, valutare invio a specialista.', when: '2026-07-07' },
|
||||
{ text: 'Aggiornamento protocollo misurazioni: aggiunta soglia energia minima.', when: '2026-07-05' },
|
||||
{ clientId: 'c6', text: 'Programma di riabilitazione in corso, buoni progressi sul recupero.', when: '2026-07-11' },
|
||||
];
|
||||
|
||||
export const studioKpis = {
|
||||
totalClients: 10,
|
||||
avgStress: 58,
|
||||
activeAlerts: 8,
|
||||
measurementsThisMonth: 46,
|
||||
retentionRate: 92,
|
||||
};
|
||||
|
||||
// `fill` colora la singola barra: le fasce basse sono "buone", le alte critiche.
|
||||
export const scoreDistribution = [
|
||||
{ range: '0-20', count: 0, fill: '#6f8f6a' },
|
||||
{ range: '21-40', count: 2, fill: '#6f8f6a' },
|
||||
{ range: '41-60', count: 3, fill: '#c08a3e' },
|
||||
{ range: '61-80', count: 4, fill: '#b05a4e' },
|
||||
{ range: '81-100', count: 1, fill: '#8d3f34' },
|
||||
];
|
||||
|
||||
export const segments = [
|
||||
{ label: 'Sportivi', count: 3, avgStress: 37 },
|
||||
{ label: 'Stress lavorativo', count: 4, avgStress: 69 },
|
||||
{ label: 'Sonno/recupero', count: 3, avgStress: 61 },
|
||||
];
|
||||
|
||||
export const topPerformers = [
|
||||
{ clientId: 'c8', name: 'C. Greco', stress: 30 },
|
||||
{ clientId: 'c2', name: 'A. Neri', stress: 38 },
|
||||
{ clientId: 'c5', name: 'P. Colombo', stress: 44 },
|
||||
];
|
||||
|
||||
export const criticalClients = [
|
||||
{ clientId: 'c7', name: 'D. Marino', stress: 82 },
|
||||
{ clientId: 'c3', name: 'G. Rossi', stress: 78 },
|
||||
{ clientId: 'c6', name: 'E. Ricci', stress: 70 },
|
||||
];
|
||||
|
||||
// Ultimo giorno coperto dai dati dimostrativi dello Sport.
|
||||
const OGGI_SPORT = '2026-07-12';
|
||||
|
||||
export type Session = {
|
||||
id: string;
|
||||
athleteId: string;
|
||||
/** Inizio della sessione, data e ora locali. */
|
||||
startedAt: string;
|
||||
durationSec: number;
|
||||
/** Lo sport può non essere indicato: l'app lo ricava dal sensore solo se dichiarato. */
|
||||
sport: string | null;
|
||||
hrAvg: number;
|
||||
hrMax: number;
|
||||
trimp: number;
|
||||
/** DFA Alpha1 a fine sessione; assente sulle registrazioni troppo brevi. */
|
||||
dfaA1: number | null;
|
||||
/** Sforzo percepito 1-10, inserito a mano dall'atleta. */
|
||||
rpe: number | null;
|
||||
};
|
||||
|
||||
export type Athlete = { clientId: string; name: string; sport: string | null };
|
||||
|
||||
export const athletes: Athlete[] = [
|
||||
{ clientId: 'c2', name: 'A. Neri', sport: 'Corsa' },
|
||||
{ clientId: 'c5', name: 'P. Colombo', sport: 'Ciclismo' },
|
||||
{ clientId: 'c8', name: 'C. Greco', sport: null },
|
||||
];
|
||||
|
||||
// Venti sessioni distribuite sugli ultimi due mesi, generate in modo deterministico dallo
|
||||
// stesso schema seno/coseno usato per le misurazioni: nessun valore casuale, così server e
|
||||
// client disegnano gli stessi grafici.
|
||||
function buildSessions(): Session[] {
|
||||
const out: Session[] = [];
|
||||
const fine = new Date(`${OGGI_SPORT}T00:00:00`);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const atleta = athletes[i % athletes.length];
|
||||
const giorno = new Date(fine);
|
||||
giorno.setDate(giorno.getDate() - Math.round(i * 2.6));
|
||||
const seed = i * 7 + atleta.clientId.charCodeAt(1);
|
||||
const ora = 7 + (i % 12);
|
||||
const minuti = (seed * 13) % 60;
|
||||
const durata = i % 9 === 0 ? 5 + (seed % 40) : 540 + Math.round(Math.abs(Math.sin(seed)) * 4400);
|
||||
const hrAvg = 95 + Math.round(Math.abs(Math.cos(seed)) * 55);
|
||||
out.push({
|
||||
id: `s${i + 1}`,
|
||||
athleteId: atleta.clientId,
|
||||
startedAt: `${giorno.toISOString().slice(0, 10)}T${String(ora).padStart(2, '0')}:${String(minuti).padStart(2, '0')}`,
|
||||
durationSec: durata,
|
||||
sport: atleta.sport,
|
||||
hrAvg,
|
||||
hrMax: hrAvg + 15 + (seed % 25),
|
||||
trimp: durata < 300 ? 0 : Math.max(4, Math.round((durata / 60) * (hrAvg / 110))),
|
||||
dfaA1: durata < 300 ? null : Math.round((0.85 + Math.abs(Math.sin(seed * 1.7)) * 0.85) * 100) / 100,
|
||||
rpe: i % 7 === 2 ? null : 3 + (seed % 6),
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
||||
}
|
||||
|
||||
export const sessions: Session[] = buildSessions();
|
||||
|
||||
// "Oggi" della piattaforma: i dati dimostrativi sono ancorati a questa data, quindi i conteggi
|
||||
// "giorni fa" restano coerenti invece di crescere col passare del tempo reale.
|
||||
export const OGGI = '2026-07-13';
|
||||
|
||||
export function daysSince(date: string, from: string = OGGI): number {
|
||||
return Math.max(0, Math.round((Date.parse(from) - Date.parse(date)) / 86_400_000));
|
||||
}
|
||||
|
||||
export function findClient(id: string): Client | null {
|
||||
return clients.find((c) => c.id === id) ?? null;
|
||||
}
|
||||
|
||||
export const professional = {
|
||||
name: 'Dr. M. Bianchi',
|
||||
role: 'Osteopata',
|
||||
studio: 'Studio Bianchi — Osteopatia e Benessere',
|
||||
email: 'm.bianchi@esempio.it',
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
// Calcoli e formattazioni del modulo Sport, tenuti fuori dalla vista perché sono la parte
|
||||
// con una logica propria (finestre temporali, aggregazioni per settimana, riepiloghi atleta).
|
||||
import type { Athlete, Session } from './data';
|
||||
|
||||
/** "1h 21m" oltre l'ora, "54m 35s" sopra il minuto, "5s" sotto. */
|
||||
export function durata(sec: number): string {
|
||||
if (sec >= 3600) return `${Math.floor(sec / 3600)}h ${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}m`;
|
||||
if (sec >= 60) return `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, '0')}s`;
|
||||
return `${sec}s`;
|
||||
}
|
||||
|
||||
const MESI = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
|
||||
|
||||
/** "30 lug 2026" */
|
||||
export function dataBreve(iso: string): string {
|
||||
const [y, m, d] = iso.slice(0, 10).split('-');
|
||||
return `${Number(d)} ${MESI[Number(m) - 1]} ${y}`;
|
||||
}
|
||||
|
||||
/** "30 lug 2026 alle 11:27" */
|
||||
export function dataOra(iso: string): string {
|
||||
return `${dataBreve(iso)} alle ${iso.slice(11, 16)}`;
|
||||
}
|
||||
|
||||
const giorniFra = (a: string, b: string) =>
|
||||
Math.round((Date.parse(b.slice(0, 10)) - Date.parse(a.slice(0, 10))) / 86_400_000);
|
||||
|
||||
/** Sessioni degli ultimi `giorni` rispetto alla data di riferimento. */
|
||||
export function ultimi(sessions: Session[], giorni: number, riferimento: string): Session[] {
|
||||
return sessions.filter((s) => {
|
||||
const d = giorniFra(s.startedAt, riferimento);
|
||||
return d >= 0 && d < giorni;
|
||||
});
|
||||
}
|
||||
|
||||
export interface KpiSport { label: string; value: number; note?: string }
|
||||
|
||||
export function kpiSport(sessions: Session[], riferimento: string): KpiSport[] {
|
||||
const settimana = ultimi(sessions, 7, riferimento);
|
||||
const mese = ultimi(sessions, 30, riferimento);
|
||||
const trimpSettimanale = settimana.reduce((n, s) => n + s.trimp, 0);
|
||||
const atletiAttivi = new Set(mese.map((s) => s.athleteId)).size;
|
||||
return [
|
||||
{ label: 'Sessioni totali', value: sessions.length },
|
||||
{ label: 'Questa settimana', value: settimana.length, note: 'ultimi 7gg' },
|
||||
{ label: 'TRIMP medio', value: trimpSettimanale, note: 'settimanale' },
|
||||
{ label: 'Atleti attivi', value: atletiAttivi, note: 'ultimi 30gg' },
|
||||
];
|
||||
}
|
||||
|
||||
/** Conteggio sessioni per settimana, etichettato col lunedì di ciascuna (dd/mm). */
|
||||
export function perSettimana(sessions: Session[], settimane: number, riferimento: string): Record<string, string | number>[] {
|
||||
// Tutta l'aritmetica sta in UTC: con date costruite in ora locale, `toISOString` riporta
|
||||
// indietro di un giorno ovunque il fuso sia positivo, e le settimane slittano al giorno prima.
|
||||
const GIORNO = 86_400_000;
|
||||
const rif = Date.parse(riferimento.slice(0, 10));
|
||||
// getUTCDay(): domenica = 0, quindi la domenica torna indietro di 6 giorni, non di -1.
|
||||
const lunedi = rif - ((new Date(rif).getUTCDay() + 6) % 7) * GIORNO;
|
||||
const righe: Record<string, string | number>[] = [];
|
||||
for (let i = settimane - 1; i >= 0; i--) {
|
||||
const inizio = lunedi - i * 7 * GIORNO;
|
||||
const da = new Date(inizio).toISOString().slice(0, 10);
|
||||
const a = new Date(inizio + 7 * GIORNO).toISOString().slice(0, 10);
|
||||
righe.push({
|
||||
settimana: `${da.slice(8, 10)}/${da.slice(5, 7)}`,
|
||||
sessioni: sessions.filter((s) => s.startedAt.slice(0, 10) >= da && s.startedAt.slice(0, 10) < a).length,
|
||||
});
|
||||
}
|
||||
return righe;
|
||||
}
|
||||
|
||||
export interface RiepilogoAtleta {
|
||||
clientId: string;
|
||||
name: string;
|
||||
sport: string | null;
|
||||
sessioni30: number;
|
||||
trimp7: number;
|
||||
ultimoTrimp: number;
|
||||
ultimaSessione: string | null;
|
||||
/** Confronto fra il carico dell'ultima settimana e quello della precedente. */
|
||||
trend: 'in crescita' | 'stabile' | 'in calo';
|
||||
}
|
||||
|
||||
export function riepilogoAtleti(sessions: Session[], athletes: Athlete[], riferimento: string): RiepilogoAtleta[] {
|
||||
return athletes.map((a) => {
|
||||
const sue = sessions.filter((s) => s.athleteId === a.clientId);
|
||||
const trimp7 = ultimi(sue, 7, riferimento).reduce((n, s) => n + s.trimp, 0);
|
||||
const settimanaPrima = sue.filter((s) => {
|
||||
const d = giorniFra(s.startedAt, riferimento);
|
||||
return d >= 7 && d < 14;
|
||||
}).reduce((n, s) => n + s.trimp, 0);
|
||||
const ultima = sue[0] ?? null;
|
||||
const scarto = trimp7 - settimanaPrima;
|
||||
return {
|
||||
clientId: a.clientId,
|
||||
name: a.name,
|
||||
sport: a.sport,
|
||||
sessioni30: ultimi(sue, 30, riferimento).length,
|
||||
trimp7,
|
||||
ultimoTrimp: ultima?.trimp ?? 0,
|
||||
ultimaSessione: ultima?.startedAt ?? null,
|
||||
trend: Math.abs(scarto) <= 10 ? 'stabile' : scarto > 0 ? 'in crescita' : 'in calo',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -14,10 +14,8 @@ export const onRequest = defineMiddleware((context, next) => {
|
||||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
// Le piattaforme riservate si raggiungono dal sito, quindi passano dal login
|
||||
// pubblico e ci tornano dopo l'accesso; il pannello ha il proprio.
|
||||
if (pathname.startsWith('/campus') || pathname.startsWith('/piattaforme') || pathname.startsWith('/longevity'))
|
||||
return context.redirect(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
// ⚠️ Qui resta un solo login, quello del pannello: il login pubblico serviva a portare
|
||||
// i clienti dentro le aree riservate, che dal 03/09/2026 stanno su apps.insanitylab.it.
|
||||
return context.redirect('/admin/login');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { login, getSessionUser, landingFor, SESSION_COOKIE } from '../../lib/auth';
|
||||
import { login, getSessionUser, logout, landingFor, isRole, SESSION_COOKIE } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
let error = '';
|
||||
@@ -9,15 +9,26 @@ if (Astro.request.method === 'POST') {
|
||||
const form = await Astro.request.formData();
|
||||
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
|
||||
if (token) {
|
||||
Astro.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
||||
});
|
||||
// Ogni ruolo atterra sulla sua sezione: campus fuori dal pannello, gli
|
||||
// altri sulla pagina admin che possono effettivamente aprire.
|
||||
return Astro.redirect(landingFor(getSessionUser(getDb(), token)?.role ?? 'user'));
|
||||
const utente = getSessionUser(getDb(), token);
|
||||
// Un account delle aree riservate rimasto in questo database (i ruoli piattaforme,
|
||||
// cliente e trainer sono emigrati il 03/09/2026) non deve entrare qui.
|
||||
// Senza questo controllo la password sarebbe giusta, la sessione verrebbe creata, e
|
||||
// poi il middleware lo rimbalzerebbe su landingFor -> /admin -> di nuovo il middleware:
|
||||
// un CICLO di redirect, che sembra un guasto del sito invece di un account nel posto
|
||||
// sbagliato. La sessione appena creata va chiusa, non lasciata aperta a vuoto.
|
||||
if (!utente || !isRole(utente.role)) {
|
||||
logout(getDb(), token);
|
||||
error = 'Questo account appartiene all area riservata: si accede da apps.insanitylab.it.';
|
||||
} else {
|
||||
Astro.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
||||
});
|
||||
return Astro.redirect(landingFor(utente.role));
|
||||
}
|
||||
} else {
|
||||
error = 'Credenziali non valide.';
|
||||
}
|
||||
error = 'Credenziali non valide.';
|
||||
}
|
||||
---
|
||||
<Admin title="Login">
|
||||
|
||||
@@ -10,10 +10,7 @@ export const prerender = false;
|
||||
const DESCRIZIONI: Partial<Record<(typeof ROLES)[number], string>> = {
|
||||
user: 'scrive i propri articoli',
|
||||
superuser: 'articoli e contenuti del sito',
|
||||
piattaforme: 'solo Campus e Stress Index',
|
||||
admin: 'accesso completo',
|
||||
cliente: 'solo il proprio fascicolo Longevity',
|
||||
trainer: 'gestionale Longevity',
|
||||
};
|
||||
---
|
||||
<Admin title="Nuovo utente">
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getLongevityDb } from '../../../lib/longevity/db';
|
||||
import { codicePerUtenteLoggato } from '../../../lib/longevity/anagrafica';
|
||||
import { salvaDalForm } from '../../../lib/longevity/vista';
|
||||
import { CAMPI_LIBERI } from '../../../lib/longevity/questionario';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/**
|
||||
* Il corpo legittimo della richiesta: SOLO i campi che un cliente può davvero mandare.
|
||||
* `client_code` non è un campo di questo tipo — non per una lista di nomi vietati (una
|
||||
* lista così invecchia appena il file rinomina la variabile, vedi il commento nel test),
|
||||
* ma perché il tipo non lo prevede: chi scrivesse domani `corpo.client_code` non
|
||||
* supererebbe `tsc --noEmit`, il campo non esiste. Il codice cliente resta SEMPRE quello
|
||||
* della sessione (codicePerUtenteLoggato, spec §8), mai un campo letto da qui.
|
||||
*/
|
||||
type CorpoQuestionario = {
|
||||
risposte: Record<string, number>;
|
||||
liberi: Record<string, string>;
|
||||
consensoSanitario: string;
|
||||
eta?: number;
|
||||
};
|
||||
|
||||
/** Estrae SOLO i campi di CorpoQuestionario da un JSON grezzo; `null` se il corpo non è
|
||||
* nemmeno un oggetto, o se manca `risposte`. Ogni altra chiave del corpo (incluso un
|
||||
* eventuale `client_code`) viene semplicemente ignorata: non esiste modo di farla arrivare
|
||||
* al chiamante, perché il tipo di ritorno non la contiene. */
|
||||
function corpoDaJson(raw: unknown): CorpoQuestionario | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null;
|
||||
const grezzo = raw as Record<string, unknown>;
|
||||
|
||||
const risposteIn = grezzo.risposte;
|
||||
if (typeof risposteIn !== 'object' || risposteIn === null) return null;
|
||||
const risposte: Record<string, number> = {};
|
||||
for (const [id, v] of Object.entries(risposteIn as Record<string, unknown>)) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) risposte[id] = v;
|
||||
}
|
||||
|
||||
// Solo i campi liberi dichiarati: un corpo con chiavi arbitrarie non ne inventa di nuove.
|
||||
const liberiIn = grezzo.liberi;
|
||||
const liberi: Record<string, string> = {};
|
||||
if (typeof liberiIn === 'object' && liberiIn !== null) {
|
||||
for (const campo of CAMPI_LIBERI) {
|
||||
const v = (liberiIn as Record<string, unknown>)[campo];
|
||||
if (typeof v === 'string') liberi[campo] = v;
|
||||
}
|
||||
}
|
||||
|
||||
const consensoSanitario = typeof grezzo.consensoSanitario === 'string' ? grezzo.consensoSanitario : '';
|
||||
const eta = typeof grezzo.eta === 'number' && Number.isFinite(grezzo.eta) ? grezzo.eta : undefined;
|
||||
|
||||
return { risposte, liberi, consensoSanitario, eta };
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
// Il codice cliente si ricava SEMPRE dalla sessione dell'utente loggato (spec §8), mai dal
|
||||
// corpo della richiesta: la risoluzione passa da anagrafica.ts (l'unico modulo autorizzato
|
||||
// a toccare quella connessione). `corpo`, qui sotto, non ha nemmeno un campo da cui
|
||||
// prenderlo per errore.
|
||||
const codice = codicePerUtenteLoggato(locals.user!.id);
|
||||
if (!codice) return json(403, { error: 'Nessun fascicolo cliente associato a questo utente.' });
|
||||
|
||||
let raw: unknown;
|
||||
try { raw = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
if (typeof raw !== 'object' || raw === null) return json(400, { error: 'Dati non validi.' });
|
||||
|
||||
const corpo = corpoDaJson(raw);
|
||||
if (!corpo) return json(400, { error: 'Risposte mancanti.' });
|
||||
const { risposte, liberi, consensoSanitario, eta } = corpo;
|
||||
|
||||
// La data della compilazione è quella del server, non quella (manomettibile) del client.
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// salvaDalForm delega a salvaCompilazione, che lancia se una risposta porta un test_id
|
||||
// sconosciuto al registro (client disallineato, non serve malizia): un'eccezione qui
|
||||
// non deve diventare un 500 generico, come in src/pages/api/contact.ts per l'invio email.
|
||||
let esito;
|
||||
try {
|
||||
esito = salvaDalForm(getLongevityDb(), {
|
||||
client_code: codice, data: oggi, eta, risposte, consensoSanitario, liberi,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Salvataggio questionario fallito:', err);
|
||||
return json(400, {
|
||||
error: 'Impossibile salvare: alcune risposte non sono riconosciute. Ricarica la pagina e riprova.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!esito.ok) return json(422, { error: esito.errore });
|
||||
return json(201, { sessioneId: esito.sessioneId });
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
---
|
||||
// Due forme di URL:
|
||||
// /campus/<area> → indice dei capitoli dell'area
|
||||
// /campus/<area>/<guida> → corpo della guida
|
||||
import Campus from '../../layouts/Campus.astro';
|
||||
import { getNav, findArea, findLesson, chapterOf, readLessonHtml } from '../../lib/campus';
|
||||
export const prerender = false;
|
||||
|
||||
const nav = getNav();
|
||||
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
|
||||
|
||||
const parts = (Astro.params.slug ?? '').split('/').filter(Boolean);
|
||||
if (parts.length === 0 || parts.length > 2) return new Response('Pagina non trovata', { status: 404 });
|
||||
|
||||
const [areaSlug, lessonSlug] = parts;
|
||||
const area = findArea(nav, areaSlug);
|
||||
if (!area) return new Response('Pagina non trovata', { status: 404 });
|
||||
|
||||
const lesson = lessonSlug ? findLesson(nav, lessonSlug) : null;
|
||||
if (lessonSlug && (!lesson || lesson.area !== areaSlug)) return new Response('Pagina non trovata', { status: 404 });
|
||||
|
||||
const body = lesson ? readLessonHtml(lesson.slug) : null;
|
||||
if (lesson && body === null) return new Response('Guida non disponibile', { status: 404 });
|
||||
|
||||
const chapter = lesson ? chapterOf(nav, lesson) : null;
|
||||
---
|
||||
<Campus title={lesson ? lesson.titleShort : area.title} nav={nav} currentArea={areaSlug} currentLesson={lesson?.slug}>
|
||||
{lesson ? (
|
||||
<article>
|
||||
<nav class="cp-crumbs">
|
||||
<a href="/campus">Biohacking</a> <span>/</span>
|
||||
<a href={`/campus/${area.slug}`}>{area.title}</a>
|
||||
{chapter && <><span>/</span> <span>{chapter.num}. {chapter.title}</span></>}
|
||||
</nav>
|
||||
<p class="eyebrow">{lesson.label} · {lesson.relatore}</p>
|
||||
<h1>{lesson.titleFull}</h1>
|
||||
{lesson.concetti.length > 0 && (
|
||||
<ul class="cp-concetti">
|
||||
{lesson.concetti.map((c) => <li>{c}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
<div class="cp-body" set:html={body} />
|
||||
<nav class="cp-prevnext">
|
||||
{lesson.prev
|
||||
? <a href={`/campus/${lesson.prev.area}/${lesson.prev.slug}`}>← {lesson.prev.title}</a>
|
||||
: <span />}
|
||||
{lesson.next && <a class="cp-next" href={`/campus/${lesson.next.area}/${lesson.next.slug}`}>{lesson.next.title} →</a>}
|
||||
</nav>
|
||||
</article>
|
||||
) : (
|
||||
<>
|
||||
<nav class="cp-crumbs"><a href="/campus">Biohacking</a> <span>/</span> <span>{area.title}</span></nav>
|
||||
<p class="eyebrow">Area di studio</p>
|
||||
<h1>{area.title}</h1>
|
||||
<p class="cp-lead">{area.sub}</p>
|
||||
{area.chapters.map((ch) => (
|
||||
<section class="cp-chapter" style={`--c:${ch.color}`}>
|
||||
<h2>{ch.num}. {ch.title}</h2>
|
||||
<p class="cp-chapter__sub">{ch.sub}</p>
|
||||
<ul class="cp-list">
|
||||
{ch.lessons.map((l) => (
|
||||
<li>
|
||||
<a href={`/campus/${area.slug}/${l.slug}`}>
|
||||
<span class="cp-list__label">{l.label}</span>
|
||||
<span class="cp-list__title">{l.titleShort}</span>
|
||||
<span class="cp-list__rel">{l.relatore}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Campus>
|
||||
|
||||
<style is:global>
|
||||
.cp-crumbs { font-size: .78rem; letter-spacing: .06em; color: var(--c-text-light); margin-bottom: 22px; }
|
||||
.cp-crumbs a { color: var(--c-accent-dark); text-decoration: none; }
|
||||
.cp-crumbs a:hover { text-decoration: underline; }
|
||||
.cp-crumbs span { margin: 0 4px; }
|
||||
|
||||
.cp-lead { font-size: 1.08rem; color: var(--c-text-light); max-width: 62ch; margin-bottom: 40px; }
|
||||
|
||||
.cp-concetti { list-style: none; margin: 26px 0 34px; padding: 20px 24px; background: var(--c-bg-alt);
|
||||
border-left: 3px solid var(--c-accent); }
|
||||
.cp-concetti li { font-size: .93rem; margin-bottom: .3em; }
|
||||
.cp-concetti li::before { content: '—'; color: var(--c-accent-dark); margin-right: 8px; }
|
||||
|
||||
.cp-chapter { margin-bottom: 42px; }
|
||||
.cp-chapter h2 { font-size: 1.25rem; text-transform: uppercase; letter-spacing: .08em; border: 0;
|
||||
padding: 0 0 0 12px; margin-bottom: .3em; border-left: 4px solid var(--c); }
|
||||
.cp-chapter__sub { color: var(--c-text-light); font-size: .92rem; padding-left: 16px; }
|
||||
|
||||
.cp-list { list-style: none; margin: 0; padding: 0; }
|
||||
.cp-list li { border-bottom: 1px solid #efece7; }
|
||||
.cp-list a { display: grid; grid-template-columns: 70px 1fr auto; gap: 14px; align-items: baseline;
|
||||
text-decoration: none; color: inherit; padding: 11px 16px; }
|
||||
.cp-list a:hover { background: var(--c-bg-alt); }
|
||||
.cp-list__label { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .06em; color: var(--c-accent-dark); }
|
||||
.cp-list__rel { font-size: .78rem; color: var(--c-text-light); text-align: right; }
|
||||
|
||||
.cp-prevnext { display: flex; justify-content: space-between; gap: 20px; margin-top: 56px;
|
||||
padding-top: 22px; border-top: 1px solid #e8e4de; font-size: .9rem; }
|
||||
.cp-prevnext a { color: var(--c-accent-dark); text-decoration: none; }
|
||||
.cp-prevnext a:hover { text-decoration: underline; }
|
||||
.cp-prevnext .cp-next { margin-left: auto; text-align: right; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cp-list a { grid-template-columns: 56px 1fr; }
|
||||
.cp-list__rel { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
// Immagini delle slide del Campus. Passano dal middleware come il resto di
|
||||
// /campus, quindi non sono raggiungibili senza login con ruolo abilitato.
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createReadStream, statSync } from 'node:fs';
|
||||
import { extname } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { resolveAsset } from '../../../lib/campus';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
export const GET: APIRoute = ({ params }) => {
|
||||
const rel = params.path;
|
||||
if (!rel) return new Response('Not found', { status: 404 });
|
||||
|
||||
const type = TYPES[extname(rel).toLowerCase()];
|
||||
if (!type) return new Response('Tipo non consentito', { status: 400 });
|
||||
|
||||
const file = resolveAsset(rel);
|
||||
if (!file) return new Response('Not found', { status: 404 });
|
||||
|
||||
const { size } = statSync(file);
|
||||
const stream = Readable.toWeb(createReadStream(file)) as ReadableStream;
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': type,
|
||||
'Content-Length': String(size),
|
||||
'Cache-Control': 'private, max-age=86400',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
import Campus from '../../layouts/Campus.astro';
|
||||
import { getNav } from '../../lib/campus';
|
||||
export const prerender = false;
|
||||
|
||||
const nav = getNav();
|
||||
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
|
||||
---
|
||||
<Campus title="Guida allo studio" nav={nav}>
|
||||
<p class="eyebrow">Biohacking Campus 2025/2026</p>
|
||||
<h1>Guida allo studio</h1>
|
||||
<p class="cp-lead">
|
||||
Le sintesi di tutte le lezioni del corso, organizzate in cinque aree:
|
||||
{nav.count} guide con concetti chiave, sviluppo esteso e materiali originali.
|
||||
</p>
|
||||
|
||||
<div class="cp-grid">
|
||||
{nav.areas.map((area) => (
|
||||
<a class="cp-card" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>
|
||||
<h2>{area.title}</h2>
|
||||
<p>{area.sub}</p>
|
||||
<ul>
|
||||
{area.chapters.map((ch) => <li>{ch.num}. {ch.title} <span>({ch.lessons.length})</span></li>)}
|
||||
</ul>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Campus>
|
||||
|
||||
<style is:global>
|
||||
.cp-lead { font-size: 1.1rem; color: var(--c-text-light); max-width: 60ch; margin-bottom: 40px; }
|
||||
.cp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 20px; }
|
||||
.cp-card { display: block; text-decoration: none; color: inherit; background: #fff;
|
||||
border: 1px solid #e8e4de; border-top: 4px solid var(--c); padding: 22px 24px; transition: box-shadow .2s, transform .2s; }
|
||||
.cp-card:hover { transform: translateY(-3px); box-shadow: 0 12px 28px rgba(58,41,41,.10); }
|
||||
.cp-card h2 { font-size: 1.15rem; margin: 0 0 .4em; border: 0; padding: 0; letter-spacing: .06em;
|
||||
text-transform: uppercase; }
|
||||
.cp-card p { font-size: .92rem; color: var(--c-text-light); margin-bottom: .8em; }
|
||||
.cp-card ul { list-style: none; margin: 0; padding: 12px 0 0; border-top: 1px solid #efece7;
|
||||
font-size: .82rem; color: var(--c-text-light); }
|
||||
.cp-card ul li { margin: 0; padding: 2px 0; }
|
||||
.cp-card ul span { color: var(--c-accent-dark); }
|
||||
</style>
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { getDb } from '../lib/db';
|
||||
import { login, getSessionUser, SESSION_COOKIE } from '../lib/auth';
|
||||
import { safeNext } from '../lib/safe-next';
|
||||
export const prerender = false;
|
||||
|
||||
const dest = safeNext(Astro.url.searchParams.get('next'));
|
||||
|
||||
// Già loggato → via dal login.
|
||||
const existing = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (existing && getSessionUser(getDb(), existing)) return Astro.redirect(dest);
|
||||
|
||||
let error = '';
|
||||
if (Astro.request.method === 'POST') {
|
||||
const form = await Astro.request.formData();
|
||||
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
|
||||
if (token) {
|
||||
Astro.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
||||
});
|
||||
return Astro.redirect(safeNext(String(form.get('next') ?? '') || dest));
|
||||
}
|
||||
error = 'Credenziali non valide.';
|
||||
}
|
||||
---
|
||||
<Base title="Accedi" description="Area riservata InsanityLab">
|
||||
<section class="section">
|
||||
<div class="container login-wrap">
|
||||
<h1 class="section-heading">Accedi</h1>
|
||||
{error && <p class="login-err">{error}</p>}
|
||||
<form method="post" class="login-form">
|
||||
<input type="hidden" name="next" value={dest} />
|
||||
<input class="login-field" name="username" placeholder="Utente" required autocomplete="username" />
|
||||
<input class="login-field" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
||||
<button class="btn btn--dark" type="submit">Entra</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.login-wrap { max-width: 380px; margin: 0 auto; }
|
||||
.login-form { display: grid; gap: 14px; margin-top: 20px; }
|
||||
.login-field { padding: 12px; border: 1px solid #ccc; font: inherit; }
|
||||
.login-err { color: #a33; }
|
||||
</style>
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
// Punto di ingresso della piattaforma: non c'è un indice, solo la scorciatoia al
|
||||
// referto del cliente. La protezione della rotta è già del middleware (isProtectedPath
|
||||
// e canAccessAdminPath in src/lib/auth.ts).
|
||||
export const prerender = false;
|
||||
return Astro.redirect('/longevity/io');
|
||||
---
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
// Il referto del cliente: il radar a sette assi, la Fitness Age, le carte dei macro-score.
|
||||
// La pagina non interroga il database da sola (c'è un test che lo verifica): il codice
|
||||
// cliente arriva da codicePerUtenteLoggato (anagrafica.ts, l'unico modulo autorizzato a
|
||||
// toccare identity), la sessione più recente e il referto stesso da vista.ts.
|
||||
import Longevity from '../../layouts/Longevity.astro';
|
||||
import Radar from '../../components/longevity/Radar';
|
||||
import MacroScore from '../../components/longevity/MacroScore.astro';
|
||||
import { getLongevityDb } from '../../lib/longevity/db';
|
||||
import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica';
|
||||
import { refertoDi, ultimaSessioneDi } from '../../lib/longevity/vista';
|
||||
import { formattaNumero } from '../../lib/longevity/formato';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const user = Astro.locals.user!;
|
||||
const codice = codicePerUtenteLoggato(user.id);
|
||||
const db = getLongevityDb();
|
||||
const sessioneId = codice ? ultimaSessioneDi(db, codice) : null;
|
||||
const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
|
||||
---
|
||||
<Longevity title="Il tuo referto" crumbs={['Il tuo referto']}>
|
||||
{!codice && (
|
||||
<div class="lg-card">
|
||||
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{codice && !referto && (
|
||||
<div class="lg-card lg-card--incompleto">
|
||||
<p>Non hai ancora compilato il questionario: senza quello non c'è ancora un referto da mostrare.</p>
|
||||
<p><a href="/longevity/questionario">Comincia da qui</a>.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{referto && (
|
||||
<div class="lg-referto">
|
||||
<section class:list={['lg-card', { 'lg-card--incompleto': referto.fitnessAge === null }]}>
|
||||
<p class="lg-score__label">Fitness Age</p>
|
||||
<div class="lg-score">
|
||||
<span class="lg-score__value lg-num">
|
||||
{referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'}
|
||||
</span>
|
||||
{referto.fitnessAge !== null && <span class="lg-score__max">anni</span>}
|
||||
</div>
|
||||
<p class="lg-muted lg-macro__nota">
|
||||
Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}%
|
||||
{referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="lg-card">
|
||||
<h2>Il tuo radar</h2>
|
||||
<Radar client:load assi={referto.assi} />
|
||||
</section>
|
||||
|
||||
<section class="lg-macro">
|
||||
{referto.macro.map((m) => (
|
||||
<MacroScore nome={m.nome} valore={m.valore} copertura={m.copertura} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section class="lg-card">
|
||||
<p>
|
||||
InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni
|
||||
raccontano come cambi, non solo come stai oggi.
|
||||
<a href="/longevity/questionario">Compila di nuovo il questionario</a>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Longevity>
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
// Il questionario in pagina, sostituisce il prototipo del cliente. La pagina non
|
||||
// interroga il database da sola: chiede i campi a `campiDelQuestionario` (letti dal
|
||||
// registro), l'isola React fa il resto — quattro blocchi, e l'invio va a
|
||||
// /api/longevity/questionario, dove il consenso si verifica davvero.
|
||||
import Longevity from '../../layouts/Longevity.astro';
|
||||
import Questionario from '../../components/longevity/Questionario';
|
||||
import { getLongevityDb } from '../../lib/longevity/db';
|
||||
import { campiDelQuestionario } from '../../lib/longevity/vista';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
const campi = campiDelQuestionario(getLongevityDb(), oggi);
|
||||
---
|
||||
<Longevity title="Questionario" crumbs={['Questionario']}>
|
||||
<div class="lg-head">
|
||||
<h1>Questionario</h1>
|
||||
<p>Rispondi con calma: puoi tornare indietro finché non invii.</p>
|
||||
</div>
|
||||
<Questionario client:load campi={campi} />
|
||||
</Longevity>
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
// Indice delle piattaforme riservate: è la pagina di atterraggio del ruolo `piattaforme`
|
||||
// (vedi landingFor in lib/auth). L'accesso è già filtrato dal middleware.
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { piattaforme } from '../../data/piattaforme';
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title="Piattaforme" description="Le piattaforme riservate InsanityLab.">
|
||||
<section class="section plat-index">
|
||||
<div class="container">
|
||||
<p class="plat-index__eyebrow">Area riservata</p>
|
||||
<h1>Piattaforme</h1>
|
||||
<p class="plat-index__lead">Gli strumenti riservati agli utenti abilitati.</p>
|
||||
<div class="plat-index__grid">
|
||||
{piattaforme.map((p) => (
|
||||
<a class="plat-card" href={p.href}>
|
||||
<h2 class="plat-card__title">{p.label}</h2>
|
||||
<p class="plat-card__desc">{p.description}</p>
|
||||
<span class="plat-card__cta">Entra</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.plat-index__eyebrow {
|
||||
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; color: var(--c-accent-dark); margin: 0 0 .6em;
|
||||
}
|
||||
.plat-index__lead { max-width: 46ch; color: var(--c-text-light); margin-bottom: 46px; }
|
||||
.plat-index__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; }
|
||||
.plat-card {
|
||||
display: block; text-decoration: none; background: var(--c-bg-alt); padding: 34px 30px 30px;
|
||||
border-top: 3px solid var(--c-accent); transition: transform .3s ease, box-shadow .3s ease;
|
||||
}
|
||||
.plat-card:hover { transform: translateY(-4px); box-shadow: 0 14px 30px rgba(0,0,0,.08); }
|
||||
.plat-card__title { font-size: 1.25rem; margin-bottom: .4em; }
|
||||
.plat-card__desc { color: var(--c-text-light); font-size: .92rem; margin-bottom: 1.4em; }
|
||||
.plat-card__cta {
|
||||
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; color: var(--c-accent-dark);
|
||||
}
|
||||
.plat-card__cta::after { content: ' →'; }
|
||||
</style>
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
// Analytics di studio. La pagina fornisce i dati e l'intervallo iniziale; il resto — filtri,
|
||||
// aggregazioni e grafici — è nell'isola, perché ogni numero dipende dai controlli.
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
import AnalyticsView from '../../../components/stress-index/AnalyticsView';
|
||||
import { clients, measurementsByClient, alerts } from '../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
// Mese corrente dei dati dimostrativi.
|
||||
const DA = '2026-07-01';
|
||||
const A = '2026-07-31';
|
||||
---
|
||||
<StressIndex title="Analytics" crumbs={['Analytics']}>
|
||||
<AnalyticsView
|
||||
client:load
|
||||
clients={clients}
|
||||
measurementsByClient={measurementsByClient}
|
||||
alerts={alerts}
|
||||
from={DA}
|
||||
to={A}
|
||||
/>
|
||||
</StressIndex>
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
// Scheda del singolo cliente. La pagina raccoglie i dati e li passa all'isola React che
|
||||
// gestisce tab e interazioni.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
import ClientDetail from '../../../../components/stress-index/ClientDetail';
|
||||
import { findClient, measurementsByClient, alerts, notes } from '../../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const { id } = Astro.params;
|
||||
const client = findClient(id ?? '');
|
||||
if (!client) return Astro.redirect('/piattaforme/stress-index/clienti');
|
||||
---
|
||||
<StressIndex title={client.name} crumbs={['Clienti', client.name]}>
|
||||
<ClientDetail
|
||||
client:load
|
||||
client={client}
|
||||
measurements={measurementsByClient[client.id] ?? []}
|
||||
alerts={alerts.filter((a) => a.clientId === client.id)}
|
||||
notes={notes.filter((n) => n.clientId === client.id)}
|
||||
/>
|
||||
</StressIndex>
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
// Elenco clienti (CRM). Il filtro per nome/email è puro JS sulle righe già renderizzate:
|
||||
// i clienti sono una decina, non vale un'isola React per cercarli.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
import Card from '../../../../components/stress-index/Card.astro';
|
||||
import { clients } from '../../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const livello = (stress: number) => (stress >= 70 ? 'bad' : stress >= 45 ? 'warn' : 'ok');
|
||||
---
|
||||
<StressIndex title="Clienti" crumbs={['Clienti']}>
|
||||
<div class="si-head">
|
||||
<h1>Clienti</h1>
|
||||
<p>{clients.length} in carico</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div class="si-toolbar">
|
||||
<label class="si-field si-field--inline">
|
||||
<span class="si-visually-hidden">Cerca cliente</span>
|
||||
<input type="search" id="si-cerca" placeholder="Cerca per nome o email" autocomplete="off" />
|
||||
</label>
|
||||
<p class="si-muted si-small" id="si-conteggio">{clients.length} risultati</p>
|
||||
</div>
|
||||
|
||||
<table class="si-table" id="si-tabella-clienti">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cliente</th><th>Età</th><th>Tag</th><th>Ultima misurazione</th><th>Stress</th><th>Alert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((c) => (
|
||||
<tr data-cerca={`${c.name} ${c.email}`.toLowerCase()}>
|
||||
<td>
|
||||
<a class="si-client" href={`/piattaforme/stress-index/clienti/${c.id}`}>
|
||||
<span class="si-avatar">{c.initials}</span>
|
||||
<span>
|
||||
<span class="si-client__name">{c.name}</span>
|
||||
<span class="si-client__mail">{c.email}</span>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>{c.age} anni</td>
|
||||
<td>{c.tags[0] && <span class="si-tag">{c.tags[0]}</span>}</td>
|
||||
<td>{c.lastMeasurement}</td>
|
||||
<td>
|
||||
<div class="si-meter">
|
||||
<span class="si-bar">
|
||||
<span class:list={['si-bar__fill', `si-bar__fill--${livello(c.stress)}`]} style={`width:${c.stress}%`}></span>
|
||||
</span>
|
||||
<span class="si-table__num">{c.stress}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{c.alerts > 0
|
||||
? <span class:list={['si-badge', c.alerts >= 2 ? 'si-badge--bad' : 'si-badge--warn']}>{c.alerts}</span>
|
||||
: <span class="si-muted">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="si-muted si-small si-empty" id="si-vuoto" hidden>Nessun cliente corrisponde alla ricerca.</p>
|
||||
</Card>
|
||||
</StressIndex>
|
||||
|
||||
<script>
|
||||
// Filtro dell'elenco: nasconde le righe che non contengono il testo cercato e tiene
|
||||
// aggiornato il conteggio dei risultati.
|
||||
const input = document.getElementById('si-cerca') as HTMLInputElement | null;
|
||||
const table = document.getElementById('si-tabella-clienti');
|
||||
const counter = document.getElementById('si-conteggio');
|
||||
const empty = document.getElementById('si-vuoto');
|
||||
if (input && table && counter && empty) {
|
||||
const rows = Array.from(table.querySelectorAll<HTMLTableRowElement>('tbody tr'));
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
let visible = 0;
|
||||
for (const row of rows) {
|
||||
const match = !q || (row.dataset.cerca ?? '').includes(q);
|
||||
row.hidden = !match;
|
||||
if (match) visible += 1;
|
||||
}
|
||||
counter.textContent = `${visible} ${visible === 1 ? 'risultato' : 'risultati'}`;
|
||||
empty.hidden = visible > 0;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -1,121 +0,0 @@
|
||||
---
|
||||
// Impostazioni: profilo, notifiche, account. I campi sono ancora un modulo dimostrativo —
|
||||
// non c'è un endpoint di salvataggio — ma la struttura è quella definitiva.
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
import Card from '../../../components/stress-index/Card.astro';
|
||||
import { professional } from '../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const [nome = '', cognome = ''] = professional.name.replace(/^Dr\.?\s*/i, '').split(' ');
|
||||
const GIORNI = ['Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato', 'Domenica'];
|
||||
const ORE = ['07:00', '08:00', '09:00', '18:00', '19:00', '20:00'];
|
||||
const TABS = [
|
||||
{ key: 'profilo', label: 'Profilo' },
|
||||
{ key: 'notifiche', label: 'Notifiche' },
|
||||
{ key: 'account', label: 'Account' },
|
||||
];
|
||||
---
|
||||
<StressIndex title="Impostazioni" crumbs={['Impostazioni']}>
|
||||
<div class="si-head">
|
||||
<h1>Impostazioni</h1>
|
||||
<p>Profilo, notifiche e account</p>
|
||||
</div>
|
||||
|
||||
<div class="si-tabs" role="tablist" id="si-set-tabs">
|
||||
{TABS.map((t, i) => (
|
||||
<button type="button" role="tab" class="si-tabs__btn" data-tab={t.key} aria-selected={i === 0 ? 'true' : 'false'}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section data-panel="profilo" class="si-stack">
|
||||
<Card title="Dati professionista">
|
||||
<div class="si-form-grid">
|
||||
<label class="si-field">Titolo<input type="text" value="Dr." /></label>
|
||||
<label class="si-field">Professione<input type="text" value={professional.role} /></label>
|
||||
<label class="si-field">Nome<input type="text" value={nome} /></label>
|
||||
<label class="si-field">Cognome<input type="text" value={cognome} /></label>
|
||||
<label class="si-field">Specializzazione<input type="text" value={professional.role} /></label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Studio">
|
||||
<div class="si-form-grid">
|
||||
<label class="si-field">Nome studio<input type="text" value={professional.studio} /></label>
|
||||
<label class="si-field">Indirizzo<input type="text" placeholder="Via Roma 1, Bologna" /></label>
|
||||
<label class="si-field">Telefono<input type="tel" placeholder="+39 051 123456" /></label>
|
||||
<label class="si-field">Sito web<input type="url" placeholder="https://studio.it" /></label>
|
||||
</div>
|
||||
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Salva</button></p>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section data-panel="notifiche" hidden>
|
||||
<Card title="Preferenze di notifica">
|
||||
<ul class="si-list si-list--rows si-prefs">
|
||||
<li>
|
||||
<span>
|
||||
<strong>Riassunto settimanale via email</strong>
|
||||
<span class="si-muted si-small">Un riepilogo periodico dell'andamento dei clienti</span>
|
||||
</span>
|
||||
<input type="checkbox" checked />
|
||||
</li>
|
||||
<li>
|
||||
<span>
|
||||
<strong>Alert immediati</strong>
|
||||
<span class="si-muted si-small">Email non appena viene generato un alert cliente</span>
|
||||
</span>
|
||||
<input type="checkbox" checked />
|
||||
</li>
|
||||
<li>
|
||||
<span>
|
||||
<strong>Novità di prodotto</strong>
|
||||
<span class="si-muted si-small">Aggiornamenti sulle nuove funzionalità</span>
|
||||
</span>
|
||||
<input type="checkbox" />
|
||||
</li>
|
||||
</ul>
|
||||
<div class="si-form-grid si-mt">
|
||||
<label class="si-field">Giorno dell'invio
|
||||
<select>{GIORNI.map((g) => <option>{g}</option>)}</select>
|
||||
</label>
|
||||
<label class="si-field">Ora dell'invio
|
||||
<select>{ORE.map((o) => <option>{o}</option>)}</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Salva preferenze</button></p>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section data-panel="account" hidden class="si-stack">
|
||||
<Card title="Accesso">
|
||||
<label class="si-field">Email<input type="email" value={professional.email} readonly /></label>
|
||||
<p class="si-muted si-small si-mt">Per cambiare l'indirizzo email serve una richiesta al supporto.</p>
|
||||
<div class="si-form-grid si-mt">
|
||||
<label class="si-field">Password attuale<input type="password" autocomplete="current-password" /></label>
|
||||
<label class="si-field">Nuova password<input type="password" autocomplete="new-password" /></label>
|
||||
<label class="si-field">Conferma nuova password<input type="password" autocomplete="new-password" /></label>
|
||||
</div>
|
||||
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Aggiorna password</button></p>
|
||||
</Card>
|
||||
|
||||
<Card title="Abbonamento">
|
||||
<p class="si-muted si-small">Per il piano e la fatturazione fa riferimento al supporto Stress Index.</p>
|
||||
</Card>
|
||||
|
||||
<p><a class="si-btn" href="/admin/logout">Esci dall'account</a></p>
|
||||
</section>
|
||||
</StressIndex>
|
||||
|
||||
<script>
|
||||
const tabs = document.getElementById('si-set-tabs');
|
||||
if (tabs) {
|
||||
const buttons = Array.from(tabs.querySelectorAll<HTMLButtonElement>('.si-tabs__btn'));
|
||||
const panels = Array.from(document.querySelectorAll<HTMLElement>('[data-panel]'));
|
||||
for (const btn of buttons) {
|
||||
btn.addEventListener('click', () => {
|
||||
for (const b of buttons) b.setAttribute('aria-selected', String(b === btn));
|
||||
for (const p of panels) p.hidden = p.dataset.panel !== btn.dataset.tab;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
// Dashboard "Oggi": ciò che il professionista deve guardare per primo — alert, clienti da
|
||||
// ricontattare, note recenti. L'accesso è già filtrato dal middleware (ruolo piattaforme/admin).
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
import Card from '../../../components/stress-index/Card.astro';
|
||||
import Kpi from '../../../components/stress-index/Kpi.astro';
|
||||
import AlertRow from '../../../components/stress-index/AlertRow.astro';
|
||||
import AndamentoChart from '../../../components/stress-index/charts/AndamentoChart';
|
||||
import {
|
||||
alerts, clients, measurementsByClient, notes, studioKpis, professional, OGGI, daysSince,
|
||||
} from '../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const prioritari = alerts.slice(0, 4);
|
||||
const misurazioniOggi = Object.values(measurementsByClient).flat().filter((m) => m.date === OGGI).length;
|
||||
const daContattare = [...clients]
|
||||
.sort((a, b) => daysSince(b.lastMeasurement) - daysSince(a.lastMeasurement))
|
||||
.slice(0, 5);
|
||||
const ultimeNote = [...notes]
|
||||
.sort((a, b) => (a.when < b.when ? 1 : -1))
|
||||
.slice(0, 4)
|
||||
.map((n) => ({ ...n, clientName: clients.find((c) => c.id === n.clientId)?.name }));
|
||||
---
|
||||
<StressIndex title="Oggi">
|
||||
<div class="si-head">
|
||||
<h1>Buongiorno, {professional.name.replace(/^Dr\.\s*/, '')}</h1>
|
||||
<p>Oggi, {OGGI}</p>
|
||||
</div>
|
||||
|
||||
<div class="si-grid si-grid--kpi">
|
||||
<Kpi label="Clienti attivi" value={studioKpis.totalClients} delta="+2 questo mese" trend="up" />
|
||||
<Kpi
|
||||
label="Misurazioni oggi"
|
||||
value={misurazioniOggi}
|
||||
delta={misurazioniOggi === 0 ? 'Nessuna ancora' : 'In aggiornamento'}
|
||||
trend={misurazioniOggi === 0 ? 'down' : 'up'}
|
||||
/>
|
||||
<Kpi label="Alert attivi" value={studioKpis.activeAlerts} delta="+3 questa settimana" trend="down" />
|
||||
<Kpi label="Stress medio" value={studioKpis.avgStress} delta="-4% vs mese scorso" trend="up" />
|
||||
</div>
|
||||
|
||||
<div class="si-grid si-grid--2 si-mt">
|
||||
<div class="si-stack">
|
||||
<Card title="Andamento medio aggregato">
|
||||
<AndamentoChart client:load measurementsByClient={measurementsByClient} />
|
||||
</Card>
|
||||
|
||||
<Card title="Alert prioritari" actionLabel="Vedi tutti" actionHref="/piattaforme/stress-index/clienti">
|
||||
{prioritari.map((a) => <AlertRow alert={a} />)}
|
||||
</Card>
|
||||
|
||||
<Card title="Da contattare">
|
||||
<ul class="si-list si-list--rows">
|
||||
{daContattare.map((c) => (
|
||||
<li>
|
||||
<span>
|
||||
<strong>{c.name}</strong>
|
||||
<span class="si-muted"> · ultima misurazione {daysSince(c.lastMeasurement)} giorni fa</span>
|
||||
</span>
|
||||
<a class="si-btn" href={`/piattaforme/stress-index/clienti/${c.id}`}>Scheda</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="si-stack">
|
||||
<Card title="Misurazioni di oggi">
|
||||
{misurazioniOggi === 0 ? (
|
||||
<p class="si-muted si-small">Nessuna misurazione registrata oggi. Le rilevazioni dei clienti compaiono qui appena arrivano.</p>
|
||||
) : (
|
||||
<p class="si-kpi__value">{misurazioniOggi}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Ultime note">
|
||||
<ul class="si-list">
|
||||
{ultimeNote.map((n) => (
|
||||
<li class="si-note">
|
||||
<p>{n.text}</p>
|
||||
<p class="si-note__meta">{n.clientName ?? 'Nota generale'} · {n.when}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</StressIndex>
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
// Organizzazione: raggruppa più professionisti sotto un'unica struttura. Finché non c'è
|
||||
// il backend, il modulo raccoglie solo il nome e il pulsante resta disabilitato a vuoto.
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
export const prerender = false;
|
||||
---
|
||||
<StressIndex title="Organizzazione" crumbs={['Organizzazione']}>
|
||||
<div class="si-center">
|
||||
<div class="si-card si-org">
|
||||
<h1>Crea la tua organizzazione</h1>
|
||||
<p class="si-muted si-small">
|
||||
Raggruppa più professionisti sotto un'unica organizzazione per condividere clienti,
|
||||
dati e impostazioni dello studio.
|
||||
</p>
|
||||
<label class="si-field si-mt">
|
||||
Nome organizzazione
|
||||
<input type="text" id="si-org-nome" placeholder="Studio Bianchi Associati" />
|
||||
</label>
|
||||
<p class="si-mt">
|
||||
<button type="button" class="si-btn si-btn--solid si-org__cta" id="si-org-crea" disabled>Crea organizzazione</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</StressIndex>
|
||||
|
||||
<script>
|
||||
// Il pulsante si sblocca solo con un nome valorizzato.
|
||||
const input = document.getElementById('si-org-nome') as HTMLInputElement | null;
|
||||
const btn = document.getElementById('si-org-crea') as HTMLButtonElement | null;
|
||||
if (input && btn) {
|
||||
input.addEventListener('input', () => { btn.disabled = input.value.trim().length === 0; });
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
// Modulo Sport. La pagina passa i dati; filtri, schede e aggregazioni stanno nell'isola.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
import SportView from '../../../../components/stress-index/SportView';
|
||||
import { sessions, athletes } from '../../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
// Ultimo giorno coperto dai dati dimostrativi.
|
||||
const RIFERIMENTO = '2026-07-12';
|
||||
---
|
||||
<StressIndex title="Sport" crumbs={['Sport']}>
|
||||
<SportView client:load sessions={sessions} athletes={athletes} riferimento={RIFERIMENTO} />
|
||||
</StressIndex>
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
// Monitoraggio live: finché non arriva la sorgente dati in tempo reale la vista resta
|
||||
// vuota per costruzione, e spiega cosa succederà quando un atleta avvia una sessione.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
export const prerender = false;
|
||||
|
||||
const PASSI = [
|
||||
"L'atleta avvia una sessione dall'app Stress Index collegata alla fascia cardio.",
|
||||
'I dati di frequenza cardiaca e DFA A1 arrivano in tempo reale a questa vista.',
|
||||
"La scheda dell'atleta compare qui da sola, con le metriche aggiornate in continuo.",
|
||||
'A fine sessione la scheda si chiude e il riepilogo confluisce nello storico Sport.',
|
||||
];
|
||||
---
|
||||
<StressIndex title="Team live" crumbs={['Sport', 'Team live']}>
|
||||
<div class="si-head si-head--row">
|
||||
<div>
|
||||
<h1>Team live</h1>
|
||||
<p>Nessun atleta in sessione</p>
|
||||
</div>
|
||||
<label class="si-field si-field--inline">
|
||||
<span class="si-visually-hidden">Ordina per</span>
|
||||
<select>
|
||||
<option value="nome">Ordina per nome</option>
|
||||
<option value="hr">Ordina per HR corrente</option>
|
||||
<option value="carico">Ordina per carico sessione</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="si-card si-live">
|
||||
<p class="si-live__title">Nessun atleta in sessione</p>
|
||||
<p class="si-muted si-small">Quando un atleta avvia una sessione dall'app, la sua scheda compare qui in tempo reale.</p>
|
||||
|
||||
<div class="si-live__how">
|
||||
<p class="si-card__title">Come funziona</p>
|
||||
<ol class="si-steps">
|
||||
{PASSI.map((p, i) => (
|
||||
<li><span class="si-steps__num">{i + 1}</span><span>{p}</span></li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</StressIndex>
|
||||
@@ -1,141 +0,0 @@
|
||||
/* Stile della piattaforma Longevity.
|
||||
Vive in un foglio globale, non in <style> scoped, perché le stesse classi servono sia
|
||||
ai componenti Astro sia alle isole React (questionario e radar), che lo scope di Astro
|
||||
non raggiungerebbe. Tutto è prefissato `.lg` e usa i token del sito.
|
||||
|
||||
A differenza di Stress Index qui non c'è semaforo verde/ambra/mattone sui punteggi:
|
||||
il servizio è premium ma non clinico, e un rosso su un punteggio del corpo sarebbe un
|
||||
giudizio, non un'informazione. L'intensità di un punteggio si legge nell'opacità
|
||||
dell'accento del sito (var(--lg-intensita), impostata inline dal componente che disegna
|
||||
il punteggio), mai in un colore diverso. I due soli colori di segnale locali sono:
|
||||
- --lg-tratteggio: il perimetro di ciò che non sappiamo ancora (un asse senza dati
|
||||
sufficienti), non un errore;
|
||||
- --lg-fuori-range: SOLO per una misura sospetta (es. un valore fisiologicamente
|
||||
improbabile), mai per un punteggio — segnala un problema della misura, non della
|
||||
persona. Riusa il mattone di Stress Index (--si-bad: #b05a4e). */
|
||||
|
||||
.lg {
|
||||
--lg-line: #e4dfd7;
|
||||
--lg-surface: #ffffff;
|
||||
--lg-tratteggio: #d8d2c8;
|
||||
--lg-fuori-range: #b05a4e;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--c-bg-alt);
|
||||
}
|
||||
|
||||
/* --- guscio del referto: una colonna che scorre, senza navigazione --- */
|
||||
.lg-shell {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
min-height: 100vh;
|
||||
background: var(--lg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* --- intestazione --- */
|
||||
.lg-top {
|
||||
padding: 18px 32px;
|
||||
border-bottom: 1px solid var(--lg-line);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--lg-surface);
|
||||
}
|
||||
.lg-top p { margin: 0; }
|
||||
.lg-top__crumbs {
|
||||
font-family: var(--font-heading); font-size: .74rem; letter-spacing: .14em;
|
||||
text-transform: uppercase; color: var(--c-text-light);
|
||||
}
|
||||
.lg-top__crumbs strong { color: var(--c-heading); font-weight: 600; }
|
||||
|
||||
.lg-main { flex: 1; padding: 34px 32px 70px; }
|
||||
|
||||
/* --- intestazione di pagina --- */
|
||||
.lg-head { margin-bottom: 28px; }
|
||||
.lg-head h1 { font-size: clamp(1.6rem, 3vw, 2.1rem); text-transform: none; letter-spacing: .02em; margin-bottom: .25em; }
|
||||
.lg-head p { margin: 0; color: var(--c-text-light); font-size: .9rem; }
|
||||
|
||||
/* --- la carta di un punteggio ---
|
||||
Nessun colore «buono» o «cattivo»: l'intensità si legge nell'opacità dell'accento
|
||||
(--lg-intensita, 0..1, impostata inline dal componente in base al punteggio). */
|
||||
.lg-card { background: var(--lg-surface); border: 1px solid var(--lg-line); padding: 20px 22px; }
|
||||
.lg-card--incompleto { border-style: dashed; border-color: var(--lg-tratteggio); }
|
||||
|
||||
.lg-score { display: flex; align-items: baseline; gap: 8px; }
|
||||
.lg-score__value { font-family: var(--font-heading); font-size: 1.9rem; font-weight: 500; color: var(--c-heading); }
|
||||
.lg-score__max { font-size: .82rem; color: var(--c-text-light); }
|
||||
.lg-score__label {
|
||||
font-family: var(--font-heading); font-size: .78rem; font-weight: 600;
|
||||
letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading);
|
||||
}
|
||||
|
||||
.lg-score__bar { height: 6px; background: var(--c-bg-alt); overflow: hidden; }
|
||||
.lg-score__bar-fill { height: 100%; background: var(--c-accent); opacity: var(--lg-intensita, 1); }
|
||||
|
||||
.lg-misura--fuori-range { color: var(--lg-fuori-range); font-weight: 600; }
|
||||
|
||||
.lg-muted { color: var(--c-text-light); }
|
||||
|
||||
/* i numeri che si confrontano nel tempo non devono ballare a ogni cifra */
|
||||
.lg-num { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* --- il referto: colonna di sezioni --- */
|
||||
.lg-referto { display: flex; flex-direction: column; gap: 24px; }
|
||||
|
||||
/* --- il radar dei sette assi: area piena + perimetro tratteggiato ---
|
||||
Il disegno vive nell'isola React (Radar.tsx); qui solo il testo sotto al grafico —
|
||||
l'elenco di ciò che manca, che è la parte che rende il buco un invito. */
|
||||
.lg-radar { display: flex; flex-direction: column; gap: 20px; }
|
||||
.lg-radar__grafico { width: 100%; }
|
||||
.lg-radar__intro { margin: 0; font-size: .86rem; color: var(--c-text-light); }
|
||||
.lg-radar__mancano { display: flex; flex-direction: column; gap: 14px; }
|
||||
.lg-radar__voce { border-top: 1px dashed var(--lg-tratteggio); padding-top: 12px; }
|
||||
.lg-radar__voce-nome {
|
||||
margin: 0 0 6px; font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
|
||||
color: var(--c-heading);
|
||||
}
|
||||
.lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); }
|
||||
.lg-radar__lista li { margin-bottom: 2px; }
|
||||
|
||||
/* --- le carte dei macro-score --- */
|
||||
.lg-macro { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; }
|
||||
.lg-macro__nota { margin: 8px 0 0; font-size: .78rem; }
|
||||
|
||||
/* --- il questionario: un blocco alla volta, avanti/indietro --- */
|
||||
.lg-form { display: flex; flex-direction: column; gap: 24px; }
|
||||
.lg-form__passo {
|
||||
font-family: var(--font-heading); font-size: .74rem; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--c-text-light); margin: 0;
|
||||
}
|
||||
.lg-form__blocco { display: flex; flex-direction: column; gap: 16px; }
|
||||
.lg-form__blocco h2 { font-size: 1.1rem; margin: 0; }
|
||||
|
||||
.lg-field { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; }
|
||||
.lg-field > span { color: var(--c-heading); font-weight: 500; }
|
||||
.lg-field input[type="number"],
|
||||
.lg-field textarea {
|
||||
border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface);
|
||||
}
|
||||
.lg-field textarea { min-height: 68px; resize: vertical; }
|
||||
|
||||
.lg-field--consenso { border: 1px solid var(--lg-line); padding: 16px 18px; gap: 10px; }
|
||||
.lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); }
|
||||
.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
||||
|
||||
.lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; }
|
||||
|
||||
.lg-form__nav { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.lg-form__nav button {
|
||||
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .08em;
|
||||
text-transform: uppercase; padding: 11px 22px; border: 1px solid var(--c-heading);
|
||||
background: transparent; color: var(--c-heading); cursor: pointer;
|
||||
}
|
||||
.lg-form__nav button:disabled { opacity: .35; cursor: not-allowed; }
|
||||
.lg-form__nav button:last-child:not(:only-child) { background: var(--c-accent); border-color: var(--c-accent); color: #fff; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lg-top, .lg-main { padding-left: 20px; padding-right: 20px; }
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
/* Stile della piattaforma Stress Index.
|
||||
Vive in un foglio globale, non in <style> scoped, perché le stesse classi servono sia
|
||||
ai componenti Astro sia alle isole React (grafici e viste interattive), che lo scope
|
||||
di Astro non raggiungerebbe. Tutto è prefissato `.si` e usa i token del sito. */
|
||||
|
||||
.si {
|
||||
--si-line: #e4dfd7;
|
||||
--si-surface: #ffffff;
|
||||
--si-ok: #6f8f6a;
|
||||
--si-warn: #c08a3e;
|
||||
--si-bad: #b05a4e;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
background: var(--c-bg-alt);
|
||||
}
|
||||
|
||||
/* --- sidebar --- */
|
||||
.si-side {
|
||||
width: 250px; flex: 0 0 250px; height: 100vh; position: sticky; top: 0; overflow-y: auto;
|
||||
background: var(--c-dark); color: #cfc6bd; padding: 28px 20px 40px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.si-brand {
|
||||
display: block; font-family: var(--font-heading); font-size: 1.35rem; font-weight: 600;
|
||||
letter-spacing: .1em; text-transform: uppercase; color: #fff; text-decoration: none; line-height: 1.1;
|
||||
}
|
||||
.si-brand .eyebrow { display: block; font-size: .7rem; letter-spacing: .18em; color: var(--c-accent); margin-bottom: 5px; }
|
||||
.si-home {
|
||||
display: inline-block; margin: 16px 0 22px; font-family: var(--font-heading); font-size: .68rem;
|
||||
font-weight: 600; letter-spacing: .16em; text-transform: uppercase; color: var(--c-accent);
|
||||
text-decoration: none; border-bottom: 1px solid transparent;
|
||||
}
|
||||
.si-home:hover { border-bottom-color: var(--c-accent); }
|
||||
|
||||
.si-nav { display: flex; flex-direction: column; gap: 2px; border-top: 1px solid rgba(255,255,255,.12); padding-top: 16px; }
|
||||
.si-nav__link {
|
||||
display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-decoration: none;
|
||||
font-family: var(--font-heading); font-size: .74rem; font-weight: 600; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: #b6ada3; border-left: 3px solid transparent;
|
||||
}
|
||||
.si-nav__link:hover { color: #fff; background: rgba(255,255,255,.06); }
|
||||
.si-nav__link.is-active { color: #fff; background: rgba(184,169,140,.22); border-left-color: var(--c-accent); }
|
||||
.si-nav__badge {
|
||||
margin-left: auto; background: var(--c-accent); color: var(--c-dark); border-radius: 999px;
|
||||
padding: 1px 7px; font-size: .58rem; letter-spacing: .1em;
|
||||
}
|
||||
|
||||
/* --- shell contenuto --- */
|
||||
.si-body { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 100vh; }
|
||||
.si-top {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: 20px;
|
||||
background: var(--si-surface); border-bottom: 1px solid var(--si-line); padding: 16px 32px;
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.si-top p { margin: 0; }
|
||||
.si-top__crumbs { font-family: var(--font-heading); font-size: .74rem; letter-spacing: .14em; text-transform: uppercase; color: var(--c-text-light); }
|
||||
.si-top__crumbs strong { color: var(--c-heading); font-weight: 600; }
|
||||
.si-top__studio { font-size: .8rem; color: var(--c-text-light); }
|
||||
.si-main { padding: 34px 32px 70px; }
|
||||
|
||||
/* --- intestazione di pagina --- */
|
||||
.si-head { margin-bottom: 28px; }
|
||||
.si-head h1 { font-size: clamp(1.6rem, 3vw, 2.1rem); text-transform: none; letter-spacing: .02em; margin-bottom: .25em; }
|
||||
.si-head p { margin: 0; color: var(--c-text-light); font-size: .9rem; }
|
||||
|
||||
/* --- griglie --- */
|
||||
.si-grid { display: grid; gap: 20px; }
|
||||
.si-grid--kpi { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
.si-grid--2 { grid-template-columns: 2fr 1fr; align-items: start; }
|
||||
.si-grid--halves { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
||||
.si-stack { display: flex; flex-direction: column; gap: 20px; }
|
||||
.si-mt { margin-top: 20px; }
|
||||
|
||||
/* --- card --- */
|
||||
.si-card { background: var(--si-surface); border: 1px solid var(--si-line); padding: 20px 22px; }
|
||||
.si-card__head { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 16px; }
|
||||
.si-card__title {
|
||||
margin: 0; font-family: var(--font-heading); font-size: .78rem; font-weight: 600;
|
||||
letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading);
|
||||
}
|
||||
.si-card__action { font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--c-accent-dark); text-decoration: none; }
|
||||
.si-card__action:hover { text-decoration: underline; }
|
||||
|
||||
/* --- kpi --- */
|
||||
.si-kpi__label { font-size: .78rem; color: var(--c-text-light); letter-spacing: .04em; }
|
||||
.si-kpi__value { font-family: var(--font-heading); font-size: 2rem; font-weight: 500; color: var(--c-heading); margin: 6px 0 4px; }
|
||||
.si-kpi__delta { font-size: .78rem; font-weight: 600; }
|
||||
.si-kpi__delta--up { color: var(--si-ok); }
|
||||
.si-kpi__delta--down { color: var(--si-bad); }
|
||||
|
||||
/* --- liste e tabelle --- */
|
||||
.si-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.si-list--rows > li { display: flex; align-items: center; justify-content: space-between; gap: 14px; font-size: .88rem; }
|
||||
.si-list--defs .si-list__row { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; font-size: .88rem; }
|
||||
.si-list--defs dd { margin: 0; color: var(--c-heading); }
|
||||
.si-muted { color: var(--c-text-light); }
|
||||
.si-small { font-size: .78rem; }
|
||||
.si-note p { margin: 0; font-size: .84rem; }
|
||||
.si-note__meta { margin-top: 2px; font-size: .76rem; color: var(--c-text-light); }
|
||||
|
||||
.si-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.si-table th, .si-table td { border-bottom: 1px solid var(--si-line); padding: 11px 10px; text-align: left; vertical-align: middle; }
|
||||
.si-table th {
|
||||
font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .14em;
|
||||
text-transform: uppercase; color: var(--c-text-light);
|
||||
}
|
||||
.si-table tbody tr:hover { background: var(--c-bg-alt); }
|
||||
.si-table a { color: var(--c-accent-dark); font-weight: 600; text-decoration: none; }
|
||||
.si-table a:hover { text-decoration: underline; }
|
||||
.si-table__num { font-family: var(--font-heading); }
|
||||
|
||||
/* --- elementi --- */
|
||||
.si-avatar {
|
||||
display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px;
|
||||
border-radius: 50%; background: var(--c-accent); color: #fff; font-family: var(--font-heading);
|
||||
font-size: .74rem; font-weight: 600; letter-spacing: .04em; flex: 0 0 34px;
|
||||
}
|
||||
.si-tag {
|
||||
display: inline-block; border: 1px solid var(--si-line); padding: 2px 9px; margin-right: 5px;
|
||||
font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--c-text-light);
|
||||
}
|
||||
.si-badge { display: inline-block; padding: 2px 9px; font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.si-badge--ok { background: rgba(111,143,106,.16); color: var(--si-ok); }
|
||||
.si-badge--warn { background: rgba(192,138,62,.16); color: var(--si-warn); }
|
||||
.si-badge--bad { background: rgba(176,90,78,.16); color: var(--si-bad); }
|
||||
|
||||
.si-alert { display: flex; gap: 12px; align-items: flex-start; padding: 12px 0; border-bottom: 1px solid var(--si-line); }
|
||||
.si-alert:last-child { border-bottom: 0; }
|
||||
.si-alert__mark { width: 7px; height: 7px; border-radius: 50%; margin-top: 7px; flex: 0 0 7px; background: var(--si-warn); }
|
||||
.si-alert--error .si-alert__mark { background: var(--si-bad); }
|
||||
.si-alert__text { font-size: .88rem; font-weight: 600; color: var(--c-heading); }
|
||||
.si-alert__meta { font-size: .76rem; color: var(--c-text-light); }
|
||||
|
||||
.si-btn {
|
||||
display: inline-block; border: 1px solid var(--si-line); background: none; cursor: pointer;
|
||||
padding: 6px 14px; font-family: var(--font-heading); font-size: .66rem; font-weight: 600;
|
||||
letter-spacing: .14em; text-transform: uppercase; color: var(--c-heading); text-decoration: none;
|
||||
}
|
||||
.si-btn:hover { border-color: var(--c-accent); color: var(--c-accent-dark); }
|
||||
.si-btn--solid { background: var(--c-dark); border-color: var(--c-dark); color: #fff; }
|
||||
.si-btn--solid:hover { background: var(--c-dark-2); color: #fff; }
|
||||
|
||||
.si-field { display: flex; flex-direction: column; gap: 6px; font-size: .82rem; color: var(--c-text-light); }
|
||||
.si-field input, .si-field select, .si-field textarea {
|
||||
border: 1px solid var(--si-line); background: #fff; padding: 9px 11px; font: inherit;
|
||||
font-size: .88rem; color: var(--c-text); border-radius: 0;
|
||||
}
|
||||
.si-field input:focus, .si-field select:focus, .si-field textarea:focus { outline: 2px solid var(--c-accent); outline-offset: -2px; }
|
||||
|
||||
/* --- misura, barre --- */
|
||||
.si-bar { height: 6px; background: var(--c-bg-alt); overflow: hidden; }
|
||||
.si-bar__fill { height: 100%; background: var(--c-accent); }
|
||||
.si-bar__fill--ok { background: var(--si-ok); }
|
||||
.si-bar__fill--warn { background: var(--si-warn); }
|
||||
.si-bar__fill--bad { background: var(--si-bad); }
|
||||
|
||||
.si-score { display: flex; align-items: baseline; gap: 8px; }
|
||||
.si-score__value { font-family: var(--font-heading); font-size: 1.9rem; font-weight: 500; color: var(--c-heading); }
|
||||
.si-score__max { font-size: .82rem; color: var(--c-text-light); }
|
||||
|
||||
/* --- tab (isole React) --- */
|
||||
.si-tabs { display: flex; gap: 26px; border-bottom: 1px solid var(--si-line); margin-bottom: 22px; }
|
||||
.si-tabs__btn {
|
||||
background: none; border: 0; border-bottom: 2px solid transparent; cursor: pointer; padding: 10px 2px;
|
||||
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .14em;
|
||||
text-transform: uppercase; color: var(--c-text-light);
|
||||
}
|
||||
.si-tabs__btn[aria-selected='true'] { color: var(--c-heading); border-bottom-color: var(--c-accent); }
|
||||
|
||||
.si-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
|
||||
.si-chip {
|
||||
background: none; border: 1px solid var(--si-line); cursor: pointer; padding: 5px 12px;
|
||||
font-family: var(--font-heading); font-size: .66rem; font-weight: 600; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--c-text-light);
|
||||
}
|
||||
.si-chip[aria-pressed='true'] { border-color: var(--c-accent); background: var(--c-bg-alt); color: var(--c-accent-dark); }
|
||||
|
||||
.si-chart { width: 100%; }
|
||||
.si-chart__empty { font-size: .85rem; color: var(--c-text-light); }
|
||||
|
||||
/* --- mobile --- */
|
||||
.si-menu-btn { display: none; }
|
||||
@media (max-width: 900px) {
|
||||
.si { display: block; }
|
||||
.si-menu-btn {
|
||||
display: block; position: sticky; top: 0; z-index: 20; background: var(--c-dark); color: #fff;
|
||||
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; padding: 14px 20px; cursor: pointer;
|
||||
}
|
||||
.si-side { display: none; width: auto; flex: none; height: auto; position: static; }
|
||||
.si-menu-toggle:checked ~ .si-side { display: flex; }
|
||||
.si-top { padding: 14px 20px; flex-direction: column; gap: 4px; }
|
||||
.si-main { padding: 24px 20px 60px; }
|
||||
.si-grid--2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* --- elenco clienti --- */
|
||||
.si-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.si-toolbar p { margin: 0; }
|
||||
.si-field--inline { flex-direction: row; align-items: center; }
|
||||
.si-field--inline input { min-width: 260px; }
|
||||
.si-visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.si-client { display: flex; align-items: center; gap: 10px; text-decoration: none; }
|
||||
.si-client__name { display: block; font-weight: 600; color: var(--c-heading); }
|
||||
.si-client__mail { display: block; font-size: .76rem; color: var(--c-text-light); font-weight: 400; }
|
||||
.si-client:hover .si-client__name { color: var(--c-accent-dark); }
|
||||
.si-meter { display: flex; align-items: center; gap: 10px; }
|
||||
.si-meter .si-bar { width: 74px; flex: 0 0 74px; }
|
||||
.si-empty { margin: 16px 0 0; }
|
||||
|
||||
/* --- scheda cliente --- */
|
||||
.si-client-head { display: flex; align-items: center; gap: 16px; }
|
||||
.si-client-head h1 { margin: 0 0 2px; font-size: clamp(1.5rem, 2.6vw, 1.9rem); text-transform: none; letter-spacing: .02em; }
|
||||
.si-client-head p { margin: 0; }
|
||||
.si-avatar--lg { width: 52px; height: 52px; flex: 0 0 52px; font-size: .95rem; }
|
||||
|
||||
.si-gauges { display: flex; flex-wrap: wrap; gap: 20px; justify-content: space-around; }
|
||||
.si-gauge { width: 150px; text-align: center; }
|
||||
.si-gauge__svg { width: 100%; height: auto; }
|
||||
.si-gauge__value { margin: 2px 0 0; font-family: var(--font-heading); font-size: 1.6rem; font-weight: 500; color: var(--c-heading); }
|
||||
.si-gauge__max { font-size: .78rem; color: var(--c-text-light); }
|
||||
.si-gauge__label { margin: 0; font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; color: var(--c-text-light); }
|
||||
|
||||
.si-insight { background: var(--c-dark); color: #cfc6bd; padding: 22px 24px; }
|
||||
.si-insight p { margin: 0; font-size: .9rem; }
|
||||
.si-insight__title {
|
||||
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; color: var(--c-accent); margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.si-scroll-x { overflow-x: auto; }
|
||||
.si-note-card { border: 1px solid var(--si-line); padding: 12px 14px; }
|
||||
.si-note-card p { margin: 0; font-size: .86rem; }
|
||||
|
||||
.si-chat { list-style: none; margin: 0 0 18px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.si-chat__row { display: flex; }
|
||||
.si-chat__row--professionista { justify-content: flex-end; }
|
||||
.si-chat__bubble { max-width: 70%; padding: 10px 14px; background: var(--c-bg-alt); border: 1px solid var(--si-line); }
|
||||
.si-chat__row--professionista .si-chat__bubble { background: var(--c-dark); border-color: var(--c-dark); color: #efe9e1; }
|
||||
.si-chat__bubble p { margin: 0; font-size: .86rem; }
|
||||
.si-chat__when { margin-top: 4px !important; font-size: .7rem; opacity: .7; }
|
||||
.si-chat__compose { display: flex; align-items: center; gap: 10px; }
|
||||
.si-chat__compose .si-field { flex: 1; }
|
||||
.si-chat__compose input { width: 100%; }
|
||||
|
||||
.si-tag-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
|
||||
.si-tag--removable { display: inline-flex; align-items: center; gap: 7px; margin-right: 0; }
|
||||
.si-tag--removable button { background: none; border: 0; cursor: pointer; color: inherit; font-size: .7rem; padding: 0; }
|
||||
|
||||
/* --- analytics, sport, impostazioni --- */
|
||||
.si-head--row { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
|
||||
.si-segment { border: 1px solid var(--si-line); padding: 16px 18px; }
|
||||
.si-segment__label { margin: 0; font-weight: 600; color: var(--c-heading); font-size: .9rem; }
|
||||
.si-segment p { margin: 0; }
|
||||
.si-segment .si-kpi__value { margin: 6px 0 2px; }
|
||||
.si-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px; }
|
||||
.si-prefs > li { align-items: flex-start; }
|
||||
.si-prefs strong { display: block; font-size: .88rem; color: var(--c-heading); }
|
||||
.si-prefs .si-small { display: block; }
|
||||
|
||||
/* --- team live --- */
|
||||
.si-live { text-align: center; padding: 54px 26px; }
|
||||
.si-live__title { font-family: var(--font-heading); font-size: 1.05rem; color: var(--c-heading); margin: 0 0 6px; }
|
||||
.si-live__how { max-width: 560px; margin: 32px auto 0; border: 1px solid var(--si-line); padding: 20px 22px; text-align: left; background: var(--c-bg-alt); }
|
||||
.si-steps { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.si-steps li { display: flex; gap: 12px; align-items: flex-start; font-size: .86rem; color: var(--c-text); }
|
||||
.si-steps__num {
|
||||
flex: 0 0 24px; width: 24px; height: 24px; border-radius: 50%; background: var(--c-accent); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center; font-family: var(--font-heading);
|
||||
font-size: .72rem; font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- organizzazione --- */
|
||||
.si-center { display: flex; justify-content: center; padding: 40px 0; }
|
||||
.si-org { width: 100%; max-width: 460px; text-align: center; padding: 34px 32px; }
|
||||
.si-org h1 { font-size: 1.4rem; text-transform: none; letter-spacing: .02em; margin-bottom: .4em; }
|
||||
.si-org p { margin: 0; }
|
||||
.si-org .si-field { text-align: left; }
|
||||
.si-org__cta:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
/* --- analytics: intervallo e classifiche --- */
|
||||
.si-range { display: flex; align-items: center; gap: 8px; }
|
||||
.si-range__sep { color: var(--c-text-light); }
|
||||
.si-range input { padding: 7px 10px; }
|
||||
.si-card__head p { margin: 4px 0 0; }
|
||||
.si-rank { list-style: none; margin: 0; padding: 0; }
|
||||
.si-rank li { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--si-line); }
|
||||
.si-rank li:last-child { border-bottom: 0; }
|
||||
.si-rank a { text-decoration: none; color: var(--c-heading); font-weight: 600; font-size: .9rem; }
|
||||
.si-rank a:hover { color: var(--c-accent-dark); }
|
||||
.si-rank__value { display: block; text-align: right; font-family: var(--font-heading); font-size: 1.05rem; }
|
||||
.si-rank__value--ok { color: var(--si-ok); }
|
||||
.si-rank__value--bad { color: var(--si-bad); }
|
||||
.si-rank__count { display: block; text-align: right; font-size: .72rem; color: var(--c-text-light); }
|
||||
|
||||
/* --- modulo sport --- */
|
||||
.si-badge--pro { background: var(--c-accent); color: #fff; vertical-align: middle; margin-left: 8px; }
|
||||
.si-filters { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
|
||||
.si-filters__count { margin: 0 0 8px auto; }
|
||||
.si-athlete__name { font-size: 1.05rem; text-transform: none; letter-spacing: .02em; margin: 0 0 2px; }
|
||||
.si-athlete__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.si-athlete__stats > div { background: var(--c-bg-alt); padding: 14px 10px; text-align: center; }
|
||||
.si-athlete__stats p { margin: 0; }
|
||||
.si-athlete__num { font-family: var(--font-heading); font-size: 1.3rem; color: var(--c-heading); }
|
||||
.si-athlete__foot {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;
|
||||
padding-top: 14px; border-top: 1px solid var(--si-line); text-decoration: none;
|
||||
font-size: .82rem; color: var(--c-text-light);
|
||||
}
|
||||
.si-athlete__foot:hover { color: var(--c-accent-dark); }
|
||||
Reference in New Issue
Block a user