longevity: pagina /longevity/progressione, grafico e tabella dell'andamento
Progressione.tsx: una linea per macro-score (connectNulls=false: un dato mancante è un buco, non una linea inventata), tratteggio e spessore a distinguere le serie invece di un colore nuovo, riga verticale tratteggiata sui cambi di versione; sotto, la tabella per asse. Colori identici a Radar.tsx, nessuno nuovo. progressione.astro: stesso modello di io.astro (codicePerUtenteLoggato, nessuna query diretta); sotto i due punti mostra il messaggio che ne serve almeno uno in più. io.astro linka la pagina nuova. Refs #5 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
// L'andamento nel tempo: una linea per macro-score, isola React come Radar (recharts ha
|
||||
// bisogno del DOM). Stesso linguaggio visivo del radar, non uno nuovo:
|
||||
// - `connectNulls={false}` su ogni linea: un valore mancante (sessione insufficiente per
|
||||
// quel macro-score) è un BUCO nella linea, non un segmento che la attraversa come se il
|
||||
// dato ci fosse. Un buco onesto vale più di una linea continua che mente.
|
||||
// - le tre serie (PERFORMANCE/ENERGY/RECOVERY) si distinguono per tratteggio e spessore,
|
||||
// MAI per colore: qui non c'è semaforo, come nel radar (vedi longevity.css). Un colore
|
||||
// diverso per macro-score si leggerebbe come un giudizio ("questo è il buono, quello è il
|
||||
// cattivo"), che il servizio non fa.
|
||||
// - la riga verticale tratteggiata su un cambio di versione usa lo stesso colore del
|
||||
// perimetro "non lo sappiamo ancora" del radar (TRATTEGGIO): un confine dichiarato, non
|
||||
// un errore.
|
||||
import {
|
||||
ResponsiveContainer, LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, Legend, ReferenceLine,
|
||||
} from 'recharts';
|
||||
import type { PuntoProgressione } from '../../lib/longevity/progressione';
|
||||
import { formattaNumero } from '../../lib/longevity/formato';
|
||||
|
||||
interface Props {
|
||||
punti: PuntoProgressione[];
|
||||
}
|
||||
|
||||
// Stessi colori di Radar.tsx (esadecimali, non var(--...): recharts vuole attributi SVG,
|
||||
// non proprietà CSS). Non sono colori nuovi, sono una copia — vedi il commento in Radar.tsx.
|
||||
const ACCENTO = '#9c8b70';
|
||||
const TRATTEGGIO = '#d8d2c8';
|
||||
const LINEA = '#e4dfd7';
|
||||
const TESTO_CHIARO = '#8a8a8a';
|
||||
|
||||
/** 'PERFORMANCE' -> 'Performance': stessa trasformazione di MacroScore.astro, non un
|
||||
* dizionario duplicato da tenere allineato — il NOME resta quello di prodotto (formato.ts),
|
||||
* solo la resa grafica cambia maiuscola/minuscola. */
|
||||
const etichetta = (nome: string) => nome.charAt(0) + nome.slice(1).toLowerCase();
|
||||
|
||||
// Tratteggio e spessore che distinguono le tre serie senza un colore nuovo per ciascuna:
|
||||
// PERFORMANCE piena e più spessa (il macro-score principale), le altre due via via più
|
||||
// leggere. L'ordine qui non impone un ordine di lettura: è solo il fallback per un
|
||||
// macro-score che un domani non fosse fra questi tre (mai successo finora, ma
|
||||
// `progressioneDi` non hardcoda i nomi — vedi il commento lì — quindi nemmeno qui).
|
||||
const STILI_SERIE: { dash?: string; width: number }[] = [
|
||||
{ dash: undefined, width: 2.5 },
|
||||
{ dash: '6 3', width: 2 },
|
||||
{ dash: '2 2', width: 1.5 },
|
||||
];
|
||||
|
||||
export default function Progressione({ punti }: Props) {
|
||||
const nomiMacro = Array.from(new Set(punti.flatMap((p) => Object.keys(p.macro))));
|
||||
const nomiAssi = Array.from(new Set(punti.flatMap((p) => Object.keys(p.assi))));
|
||||
|
||||
const dati = punti.map((p) => {
|
||||
const riga: Record<string, string | number | null> = { data: p.data };
|
||||
for (const nome of nomiMacro) riga[nome] = p.macro[nome];
|
||||
return riga;
|
||||
});
|
||||
|
||||
const cambi = punti.filter((p) => p.cambioVersione !== null);
|
||||
|
||||
return (
|
||||
<div className="lg-progressione">
|
||||
<p className="lg-progressione__intro">
|
||||
Una linea per macro-score (Performance, Energy, Recovery): dove manca un tratto, quella
|
||||
sessione non aveva dati sufficienti per quel numero — non è uno zero, è un buco. La
|
||||
riga verticale tratteggiata segna dove è cambiato il questionario o il modo di
|
||||
calcolare: i numeri prima e dopo non si confrontano allo stesso modo.
|
||||
</p>
|
||||
|
||||
<div className="lg-progressione__grafico">
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={dati} margin={{ top: 12, right: 12, bottom: 0, left: -18 }}>
|
||||
<CartesianGrid stroke={LINEA} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="data" stroke={TESTO_CHIARO} fontSize={11} tickLine={false} />
|
||||
<YAxis domain={[0, 100]} stroke={TESTO_CHIARO} fontSize={11} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ border: `1px solid ${LINEA}`, borderRadius: 0, fontSize: 12 }}
|
||||
labelStyle={{ color: '#2b2b2b', fontWeight: 600 }}
|
||||
/>
|
||||
<Legend iconType="plainline" wrapperStyle={{ fontSize: 12 }} />
|
||||
{cambi.map((p) => (
|
||||
<ReferenceLine
|
||||
key={p.sessioneId}
|
||||
x={p.data}
|
||||
stroke={TRATTEGGIO}
|
||||
strokeDasharray="4 3"
|
||||
label={{ value: p.cambioVersione ?? '', position: 'top', fill: TESTO_CHIARO, fontSize: 10 }}
|
||||
/>
|
||||
))}
|
||||
{nomiMacro.map((nome, i) => {
|
||||
const stile = STILI_SERIE[i % STILI_SERIE.length];
|
||||
return (
|
||||
<Line
|
||||
key={nome}
|
||||
type="monotone"
|
||||
dataKey={nome}
|
||||
name={etichetta(nome)}
|
||||
stroke={ACCENTO}
|
||||
strokeWidth={stile.width}
|
||||
strokeDasharray={stile.dash}
|
||||
dot={{ r: 3 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* La tabella per asse: la stessa granularità del radar (sette assi), qui nel tempo
|
||||
invece che in un solo istante. «—» dove il dato non basta, mai uno zero. */}
|
||||
<div className="lg-progressione__tabella">
|
||||
<table className="lg-progressione__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{punti.map((p) => (
|
||||
<th key={p.sessioneId} className="lg-num">{p.data}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nomiAssi.map((nome) => (
|
||||
<tr key={nome}>
|
||||
<td>{nome}</td>
|
||||
{punti.map((p) => {
|
||||
const v = p.assi[nome];
|
||||
return (
|
||||
<td key={p.sessioneId} className="lg-num">
|
||||
{v !== null && v !== undefined ? formattaNumero(v) : '—'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,7 +64,8 @@ const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
|
||||
<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>.
|
||||
<a href="/longevity/progressione">Guarda il tuo andamento nel tempo</a> o
|
||||
<a href="/longevity/questionario">compila di nuovo il questionario</a>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
// L'andamento nel tempo del cliente: una sessione per punto, i punteggi congelati che
|
||||
// quella sessione ha davvero. Stesso modello di io.astro: la pagina non interroga il
|
||||
// database da sola (c'è un test che lo verifica, referto.test.ts, esteso a tutte le pagine
|
||||
// sotto src/pages/longevity/), il codice cliente arriva da codicePerUtenteLoggato
|
||||
// (anagrafica.ts, l'unico modulo autorizzato a toccare identity), i punti da progressioneDi
|
||||
// (progressione.ts, che legge SOLO score — mai un ricalcolo).
|
||||
import Longevity from '../../layouts/Longevity.astro';
|
||||
import Progressione from '../../components/longevity/Progressione';
|
||||
import { getLongevityDb } from '../../lib/longevity/db';
|
||||
import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica';
|
||||
import { progressioneDi } from '../../lib/longevity/progressione';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const user = Astro.locals.user!;
|
||||
const codice = codicePerUtenteLoggato(user.id);
|
||||
const db = getLongevityDb();
|
||||
const punti = codice ? progressioneDi(db, codice) : [];
|
||||
---
|
||||
<Longevity title="Andamento nel tempo" crumbs={['Andamento nel tempo']}>
|
||||
{!codice && (
|
||||
<div class="lg-card">
|
||||
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{codice && punti.length < 2 && (
|
||||
<div class="lg-card lg-card--incompleto">
|
||||
<p>Serve almeno un secondo check-up o questionario per vedere un andamento.</p>
|
||||
<p><a href="/longevity/io">Torna al tuo referto</a>.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{codice && punti.length >= 2 && (
|
||||
<div class="lg-referto">
|
||||
<section class="lg-card">
|
||||
<h2>Il tuo andamento</h2>
|
||||
<Progressione client:load punti={punti} />
|
||||
</section>
|
||||
|
||||
<section class="lg-card">
|
||||
<p><a href="/longevity/io">Torna al tuo referto</a>.</p>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Longevity>
|
||||
@@ -100,6 +100,22 @@
|
||||
.lg-radar__lista { margin: 0; padding-left: 18px; font-size: .86rem; color: var(--c-text-light); }
|
||||
.lg-radar__lista li { margin-bottom: 2px; }
|
||||
|
||||
/* --- l'andamento nel tempo: il grafico vive nell'isola React (Progressione.tsx),
|
||||
qui solo il testo introduttivo e la tabella per asse sotto al grafico. --- */
|
||||
.lg-progressione { display: flex; flex-direction: column; gap: 20px; }
|
||||
.lg-progressione__intro { margin: 0; font-size: .86rem; color: var(--c-text-light); }
|
||||
.lg-progressione__grafico { width: 100%; }
|
||||
.lg-progressione__tabella { overflow-x: auto; }
|
||||
.lg-progressione__table { width: 100%; border-collapse: collapse; font-size: .82rem; }
|
||||
.lg-progressione__table th, .lg-progressione__table td {
|
||||
padding: 8px 10px; text-align: right; border-bottom: 1px solid var(--lg-line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lg-progressione__table th:first-child, .lg-progressione__table td:first-child {
|
||||
text-align: left; color: var(--c-heading); font-weight: 500;
|
||||
}
|
||||
.lg-progressione__table th { color: var(--c-text-light); font-weight: 600; }
|
||||
|
||||
/* --- 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; }
|
||||
|
||||
Reference in New Issue
Block a user