Files
Momento/memento-note/components/flashcards/revision-heatmap.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

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