feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped

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>
This commit is contained in:
Antigravity
2026-05-24 23:03:16 +00:00
parent ecd7e57c2e
commit 0784c94242
63 changed files with 10133 additions and 619 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
@@ -15,7 +15,7 @@ interface RevisionHeatmapProps {
}
function intensityClass(count: number, max: number): string {
if (count <= 0) return 'bg-black/[0.04] dark:bg-white/[0.06]'
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'
@@ -23,47 +23,159 @@ function intensityClass(count: number, max: number): string {
return 'bg-brand-accent/20'
}
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
const { t } = useLanguage()
function resolveDateLocale(langCode: string): string {
if (langCode === 'fa') return 'fa-IR-u-ca-persian-nu-arabext'
return langCode
}
const { cells, maxCount } = useMemo(() => {
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 today = new Date()
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(today)
d.setDate(d.getDate() - 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: map.get(key) || 0,
label: d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }),
count,
label: d.toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }),
})
}
const maxCount = Math.max(1, ...cells.map((c) => c.count))
return { cells, maxCount }
}, [data])
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-3', className)}>
<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">{t('flashcards.heatmapLast90')}</span>
<span className="text-[10px] text-concrete/60">
{totalReviews > 0 ? `${totalReviews} révisions · 90 jours` : t('flashcards.heatmapLast90')}
</span>
</div>
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
{cells.map((cell) => (
<div
key={cell.date}
title={`${cell.label}: ${cell.count}`}
className={cn(
'aspect-square rounded-[3px] transition-colors',
intensityClass(cell.count, maxCount),
)}
/>
{/* 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>
)
}