Piattaforme riservate: ruolo dedicato e port della dashboard Stress Index

Il ruolo `campus` diventa `piattaforme` e non apre più solo la sezione
Biohacking: copre tutte le aree riservate, con migrazione guardata sugli
utenti esistenti. Nell'header la vecchia voce Biohacking lascia il posto a
un menu Piattaforme con i due link, visibile solo a chi ha il ruolo, e
/piattaforme diventa la pagina di atterraggio.

Stress Index è portata dentro il sito: le nove viste dell'area
professionisti diventano pagine Astro sotto /piattaforme/stress-index,
con lo stile del sito al posto del design system del prototipo. React
resta solo dove serve davvero — grafici recharts e scheda cliente — il
resto è Astro con un filo di JS. I dati sono ancora quelli dimostrativi,
raccolti in lib/stress-index/data.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:49:47 +02:00
parent e8e24e086b
commit c4eb598a3a
32 changed files with 2908 additions and 74 deletions
@@ -0,0 +1,56 @@
// 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>
);
}
@@ -0,0 +1,50 @@
// 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' ? (
<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>
);
}