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>
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
|
|
interface HeatmapDay {
|
|
date: string
|
|
count: number
|
|
}
|
|
|
|
interface RevisionHeatmapProps {
|
|
data: HeatmapDay[]
|
|
className?: string
|
|
}
|
|
|
|
function intensityClass(count: number, max: number): string {
|
|
if (count <= 0) return 'bg-black/[0.06] dark:bg-white/[0.08]'
|
|
const ratio = count / Math.max(max, 1)
|
|
if (ratio >= 0.75) return 'bg-brand-accent'
|
|
if (ratio >= 0.5) return 'bg-brand-accent/70'
|
|
if (ratio >= 0.25) return 'bg-brand-accent/40'
|
|
return 'bg-brand-accent/20'
|
|
}
|
|
|
|
function resolveDateLocale(langCode: string): string {
|
|
if (langCode === 'fa') return 'fa-IR-u-ca-persian-nu-arabext'
|
|
return langCode
|
|
}
|
|
|
|
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
|
|
const { t, language } = useLanguage()
|
|
const [hovered, setHovered] = useState<{ label: string; count: number; date: string } | null>(null)
|
|
const [selected, setSelected] = useState<{ label: string; count: number; date: string } | null>(null)
|
|
|
|
const dateLocale = resolveDateLocale(language ?? 'en')
|
|
|
|
const { cells, maxCount, totalReviews, monthLabels } = useMemo(() => {
|
|
const map = new Map(data.map((d) => [d.date, d.count]))
|
|
const now = new Date()
|
|
// todayUTC est le début de la journée courante à minuit UTC
|
|
const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
|
|
|
const cells: { date: string; count: number; label: string }[] = []
|
|
const monthLabels: { index: number; label: string }[] = []
|
|
let lastMonth = -1
|
|
|
|
for (let i = 89; i >= 0; i--) {
|
|
const d = new Date(todayUTC)
|
|
d.setUTCDate(d.getUTCDate() - i)
|
|
const key = d.toISOString().slice(0, 10)
|
|
const count = map.get(key) || 0
|
|
const month = d.getUTCMonth()
|
|
|
|
if (month !== lastMonth) {
|
|
monthLabels.push({
|
|
index: 89 - i,
|
|
label: d.toLocaleDateString(dateLocale, { month: 'short', timeZone: 'UTC' }),
|
|
})
|
|
lastMonth = month
|
|
}
|
|
|
|
cells.push({
|
|
date: key,
|
|
count,
|
|
label: d.toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }),
|
|
})
|
|
}
|
|
|
|
const maxCount = Math.max(1, ...cells.map((c) => c.count))
|
|
const totalReviews = cells.reduce((s, c) => s + c.count, 0)
|
|
return { cells, maxCount, totalReviews, monthLabels }
|
|
}, [data, dateLocale])
|
|
|
|
const pct = (index: number) => `${(index / 90) * 100}%`
|
|
|
|
const activeInfo = hovered || selected
|
|
|
|
return (
|
|
<div className={cn('space-y-2', className)}>
|
|
{/* En-tête */}
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
|
{t('flashcards.heatmapTitle')}
|
|
</p>
|
|
<span className="text-[10px] text-concrete/60">
|
|
{totalReviews > 0 ? `${totalReviews} révisions · 90 jours` : t('flashcards.heatmapLast90')}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Labels de mois au-dessus de la grille */}
|
|
<div className="relative h-4">
|
|
{monthLabels.map((m) => (
|
|
<span
|
|
key={m.label + m.index}
|
|
className="absolute text-[9px] text-concrete/60 font-medium translate-y-0.5"
|
|
style={{ left: pct(m.index) }}
|
|
>
|
|
{m.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* Grille pleine largeur */}
|
|
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
|
|
{cells.map((cell) => {
|
|
const isHovered = hovered?.date === cell.date
|
|
const isSelected = selected?.date === cell.date
|
|
const reviewText = cell.count > 0
|
|
? `${cell.count} révision${cell.count > 1 ? 's' : ''}`
|
|
: 'Aucune révision'
|
|
|
|
return (
|
|
<button
|
|
key={cell.date}
|
|
type="button"
|
|
title={`${reviewText} - ${cell.label}`}
|
|
className={cn(
|
|
'aspect-square rounded-[3px] transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-brand-accent focus:ring-offset-1 focus:ring-offset-background',
|
|
intensityClass(cell.count, maxCount),
|
|
(isHovered || isSelected) && 'ring-2 ring-brand-accent ring-offset-1 ring-offset-background scale-105 z-10',
|
|
)}
|
|
onMouseEnter={() => setHovered({ label: cell.label, count: cell.count, date: cell.date })}
|
|
onMouseLeave={() => setHovered(null)}
|
|
onClick={() => {
|
|
if (selected?.date === cell.date) {
|
|
setSelected(null)
|
|
} else {
|
|
setSelected({ label: cell.label, count: cell.count, date: cell.date })
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Info au survol / clic */}
|
|
<div className="h-6 flex items-center justify-between text-[11px] border-b border-border/20 pb-1">
|
|
{activeInfo ? (
|
|
<p className="flex items-center gap-1.5 animate-fadeIn">
|
|
<span className="font-semibold text-foreground">
|
|
{activeInfo.count > 0
|
|
? `${activeInfo.count} révision${activeInfo.count > 1 ? 's' : ''}`
|
|
: 'Aucune révision'}
|
|
</span>
|
|
<span className="text-concrete">· {activeInfo.label}</span>
|
|
{selected?.date === activeInfo.date && !hovered && (
|
|
<span className="text-[9px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.2 rounded-full font-medium">
|
|
sélectionné
|
|
</span>
|
|
)}
|
|
</p>
|
|
) : (
|
|
<p className="text-[10px] text-concrete/40 italic">
|
|
Survolez ou cliquez sur un carré pour voir le détail
|
|
</p>
|
|
)}
|
|
{selected && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelected(null)}
|
|
className="text-[10px] text-brand-accent hover:text-brand-accent/80 hover:underline transition-colors"
|
|
>
|
|
Effacer la sélection
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Légende */}
|
|
<div className="flex items-center gap-2 pt-0.5">
|
|
<span className="text-[9px] text-concrete/50">Moins</span>
|
|
<div className="flex gap-0.5">
|
|
{['bg-black/[0.06] dark:bg-white/[0.08]', 'bg-brand-accent/20', 'bg-brand-accent/40', 'bg-brand-accent/70', 'bg-brand-accent'].map((cls, i) => (
|
|
<div key={i} className={cn('w-3 h-3 rounded-[3px]', cls)} />
|
|
))}
|
|
</div>
|
|
<span className="text-[9px] text-concrete/50">Plus</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|