Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note) avec activation guidée, tableau éditable, kanban et suppression de colonnes. Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN. Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la robustesse du serveur MCP (config, validation, rate-limit, métriques). Co-authored-by: Cursor <cursoragent@cursor.com>
214 lines
7.5 KiB
TypeScript
214 lines
7.5 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface WeekData {
|
|
week: string
|
|
rate: number
|
|
total: number
|
|
}
|
|
|
|
interface RetentionCurveProps {
|
|
data: WeekData[]
|
|
className?: string
|
|
}
|
|
|
|
export function RetentionCurve({ data, className }: RetentionCurveProps) {
|
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
|
|
|
// Filtrer les semaines avec données
|
|
const weeks = data.filter((w) => w.total > 0)
|
|
|
|
if (weeks.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-28 border border-dashed border-border/40 rounded-xl bg-muted/10">
|
|
<p className="text-xs text-concrete/50 italic">Aucune donnée disponible</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Dimensions SVG
|
|
const width = 500
|
|
const height = 120
|
|
const padding = { top: 15, right: 30, left: 30, bottom: 25 }
|
|
const chartWidth = width - padding.left - padding.right
|
|
const chartHeight = height - padding.top - padding.bottom
|
|
|
|
// Calculer les coordonnées des points
|
|
const points = weeks.map((w, i) => {
|
|
// Si 1 seul point, on le centre
|
|
const x = weeks.length === 1
|
|
? padding.left + chartWidth / 2
|
|
: padding.left + (i * chartWidth) / (weeks.length - 1)
|
|
|
|
// Taux entre 0 et 100
|
|
const val = Math.max(0, Math.min(100, w.rate))
|
|
const y = padding.top + chartHeight * (1 - val / 100)
|
|
|
|
return { x, y, rate: w.rate, week: w.week, total: w.total }
|
|
})
|
|
|
|
// Créer le path SVG pour la ligne
|
|
let linePath = ''
|
|
let areaPath = ''
|
|
|
|
if (points.length === 1) {
|
|
// Une seule semaine : ligne pointillée horizontale + point central
|
|
const yVal = points[0].y
|
|
linePath = `M ${padding.left} ${yVal} L ${width - padding.right} ${yVal}`
|
|
} else {
|
|
// Plusieurs semaines : tracer la courbe ligne
|
|
linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
|
// Area fermée pour le dégradé sous la courbe
|
|
areaPath = `${linePath} L ${points[points.length - 1].x} ${height - padding.bottom} L ${points[0].x} ${height - padding.bottom} Z`
|
|
}
|
|
|
|
const activePoint = hoveredIndex !== null ? points[hoveredIndex] : null
|
|
|
|
return (
|
|
<div className={cn('relative w-full select-none', className)}>
|
|
{/* SVG Container */}
|
|
<svg
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
className="w-full h-auto overflow-visible"
|
|
>
|
|
<defs>
|
|
{/* Dégradé sous la courbe */}
|
|
<linearGradient id="retentionGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="var(--color-brand-accent, #6366f1)" stopOpacity="0.22" />
|
|
<stop offset="100%" stopColor="var(--color-brand-accent, #6366f1)" stopOpacity="0.0" />
|
|
</linearGradient>
|
|
|
|
{/* Lueur d'accent */}
|
|
<filter id="accentGlow" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
|
</filter>
|
|
</defs>
|
|
|
|
{/* Lignes de guide d'arrière-plan (Grid) */}
|
|
<g stroke="currentColor" strokeOpacity={0.05} strokeWidth={1}>
|
|
<line x1={padding.left} y1={padding.top} x2={width - padding.right} y2={padding.top} />
|
|
<line x1={padding.left} y1={padding.top + chartHeight / 2} x2={width - padding.right} y2={padding.top + chartHeight / 2} />
|
|
<line x1={padding.left} y1={height - padding.bottom} x2={width - padding.right} y2={height - padding.bottom} />
|
|
</g>
|
|
|
|
{/* Tracé Dégradé Rempli (uniquement si plusieurs points) */}
|
|
{points.length > 1 && areaPath && (
|
|
<path
|
|
d={areaPath}
|
|
fill="url(#retentionGrad)"
|
|
className="transition-all duration-300"
|
|
/>
|
|
)}
|
|
|
|
{/* Ligne principale de la courbe */}
|
|
<path
|
|
d={linePath}
|
|
fill="none"
|
|
stroke="var(--color-brand-accent, #6366f1)"
|
|
strokeWidth={3}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeDasharray={points.length === 1 ? '4 4' : undefined}
|
|
className="transition-all duration-300"
|
|
/>
|
|
|
|
{/* Guide vertical au survol */}
|
|
{activePoint && (
|
|
<line
|
|
x1={activePoint.x}
|
|
y1={padding.top}
|
|
x2={activePoint.x}
|
|
y2={height - padding.bottom}
|
|
stroke="var(--color-brand-accent, #6366f1)"
|
|
strokeOpacity={0.25}
|
|
strokeWidth={1.5}
|
|
strokeDasharray="2 2"
|
|
/>
|
|
)}
|
|
|
|
{/* Points et zones interactives */}
|
|
{points.map((p, idx) => {
|
|
const isHovered = hoveredIndex === idx
|
|
return (
|
|
<g key={idx} className="cursor-pointer">
|
|
{/* Cercle extérieur (ombre / lueur d'accent si survol) */}
|
|
<circle
|
|
cx={p.x}
|
|
cy={p.y}
|
|
r={isHovered ? 8 : 4}
|
|
fill="var(--color-brand-accent, #6366f1)"
|
|
fillOpacity={isHovered ? 0.3 : 0.15}
|
|
className="transition-all duration-200"
|
|
/>
|
|
|
|
{/* Cercle intérieur (le point lui-même) */}
|
|
<circle
|
|
cx={p.x}
|
|
cy={p.y}
|
|
r={isHovered ? 4.5 : 3.5}
|
|
fill="var(--color-brand-accent, #6366f1)"
|
|
stroke={isHovered ? '#fff' : 'none'}
|
|
strokeWidth={1}
|
|
className="transition-all duration-200"
|
|
filter={isHovered ? 'url(#accentGlow)' : undefined}
|
|
/>
|
|
|
|
{/* Zone invisible large pour faciliter le survol souris/toucher */}
|
|
<rect
|
|
x={p.x - 20}
|
|
y={padding.top}
|
|
width={40}
|
|
height={chartHeight}
|
|
fill="transparent"
|
|
onMouseEnter={() => setHoveredIndex(idx)}
|
|
onMouseLeave={() => setHoveredIndex(null)}
|
|
/>
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{/* Labels des semaines sous le graphe (X-Axis) */}
|
|
{points.map((p, idx) => {
|
|
const label = p.week.slice(5) // MM-DD
|
|
const isHovered = hoveredIndex === idx
|
|
// Pour éviter la surcharge visuelle, on n'affiche pas tous les labels si trop nombreux, sauf si survolé
|
|
const shouldShowLabel = points.length <= 8 || idx === 0 || idx === points.length - 1 || isHovered
|
|
|
|
if (!shouldShowLabel) return null
|
|
|
|
return (
|
|
<text
|
|
key={idx}
|
|
x={p.x}
|
|
y={height - 8}
|
|
textAnchor="middle"
|
|
className={cn(
|
|
"text-[9px] font-mono fill-concrete/60 select-none transition-colors",
|
|
isHovered && "fill-brand-accent font-bold"
|
|
)}
|
|
>
|
|
{label}
|
|
</text>
|
|
)
|
|
})}
|
|
</svg>
|
|
|
|
{/* Floating Info Tooltip */}
|
|
<div className="absolute right-0 top-0 h-4 flex items-center">
|
|
{activePoint ? (
|
|
<div className="text-[10px] text-foreground bg-card border border-border px-2 py-0.5 rounded-lg shadow-sm font-mono flex items-center gap-2 animate-fadeIn">
|
|
<span className="font-bold text-brand-accent">{activePoint.rate}% de succès</span>
|
|
<span className="text-concrete/60">({activePoint.total} révs)</span>
|
|
<span className="text-concrete/40">· sem. {activePoint.week.slice(5)}</span>
|
|
</div>
|
|
) : (
|
|
<span className="text-[9px] text-concrete/40 italic">Survolez un point pour les détails</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|