Files
Momento/memento-note/components/flashcards/retention-curve.tsx
Antigravity 0784c94242
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
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>
2026-05-24 23:03:16 +00:00

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>
)
}