Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul. Co-authored-by: Cursor <cursoragent@cursor.com>
169 lines
6.1 KiB
TypeScript
169 lines
6.1 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
|
|
/** Révisions de flashcards, ou notes modifiées sur le tableau de bord. */
|
|
kind?: 'reviews' | 'edits'
|
|
}
|
|
|
|
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, kind = 'reviews' }: 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 prefix = kind === 'edits' ? 'homeDashboard.activityHeatmap' : 'flashcards.heatmap'
|
|
|
|
const dayLabel = (count: number) => {
|
|
if (count <= 0) return t(`${prefix}DayNone`)
|
|
if (count === 1) return t(`${prefix}DayOne`)
|
|
return t(`${prefix}Day`, { count })
|
|
}
|
|
|
|
const { cells, maxCount, totalReviews, monthLabels } = useMemo(() => {
|
|
const map = new Map(data.map((d) => [d.date, d.count]))
|
|
const now = new Date()
|
|
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)}>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">
|
|
{t(`${prefix}Title`)}
|
|
</p>
|
|
<span className="text-[13px] text-concrete/70">
|
|
{totalReviews > 0
|
|
? t(`${prefix}Total`, { count: totalReviews })
|
|
: t(kind === 'edits' ? 'homeDashboard.activityHeatmapLast90' : 'flashcards.heatmapLast90')}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="relative h-6">
|
|
{monthLabels.map((m) => (
|
|
<span
|
|
key={m.label + m.index}
|
|
className="absolute text-[13px] text-concrete/70 font-medium leading-tight"
|
|
style={{ left: pct(m.index) }}
|
|
>
|
|
{m.label}
|
|
</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) => {
|
|
const isHovered = hovered?.date === cell.date
|
|
const isSelected = selected?.date === cell.date
|
|
const reviewText = dayLabel(cell.count)
|
|
|
|
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>
|
|
|
|
<div className="min-h-7 flex items-center text-[13px] border-b border-border/20 pb-1">
|
|
{activeInfo ? (
|
|
<p className="flex items-center gap-1.5 animate-fadeIn">
|
|
<span className="font-semibold text-foreground">
|
|
{dayLabel(activeInfo.count)}
|
|
</span>
|
|
<span className="text-concrete">· {activeInfo.label}</span>
|
|
</p>
|
|
) : (
|
|
<p className="text-[13px] text-concrete/50 italic">
|
|
{t(`${prefix}Hint`)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 pt-0.5">
|
|
<span className="text-[13px] text-concrete/60">{t('flashcards.heatmapLess')}</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-[13px] text-concrete/60">{t('flashcards.heatmapMore')}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|