import { useMemo, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; interface MemoryHeatmapProps { /** Array of document/relation timestamps (unix epoch seconds). */ timestamps: number[]; loading?: boolean; } const MONTHS = 8; const DAYS_PER_WEEK = 7; const CELL_GAP = 2; function dayLabels(t: (key: string) => string): string[] { return ['', t('memory.day.mon'), '', t('memory.day.wed'), '', t('memory.day.fri'), '']; } const INTENSITY_COLORS = [ 'rgba(255,255,255,0.04)', // 0 events 'rgba(74,131,221,0.25)', // 1 'rgba(74,131,221,0.45)', // 2-3 'rgba(74,131,221,0.65)', // 4-6 'rgba(74,131,221,0.85)', // 7+ ]; function getIntensity(count: number): number { if (count === 0) return 0; if (count === 1) return 1; if (count <= 3) return 2; if (count <= 6) return 3; return 4; } function dateToKey(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } function formatDate(date: Date): string { return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', }); } export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) { const { t } = useT(); const [hoveredCell, setHoveredCell] = useState<{ date: Date; count: number; x: number; y: number; } | null>(null); const { grid, monthLabels, totalEvents, maxDailyCount, totalWeeks } = useMemo(() => { // The window: 6 months ago through today const today = new Date(); today.setHours(0, 0, 0, 0); const rangeStart = new Date(today); rangeStart.setMonth(rangeStart.getMonth() - MONTHS); rangeStart.setDate(1); // start of that month // Align to the Sunday of rangeStart's week const startDate = new Date(rangeStart); startDate.setDate(startDate.getDate() - startDate.getDay()); // Count timestamps that fall anywhere (not limited to the 6-month window) // — this means ingesting old data still lights up that old date. const countMap = new Map(); let total = 0; let maxCount = 0; for (const ts of timestamps) { const date = new Date(ts > 9999999999 ? ts : ts * 1000); const key = dateToKey(date); const prev = countMap.get(key) ?? 0; const next = prev + 1; countMap.set(key, next); // Only count towards total/max if inside our display range if (date >= startDate && date <= today) { total++; if (next > maxCount) maxCount = next; } } // Build grid const cells: { date: Date; count: number; weekIdx: number; dayIdx: number }[] = []; const months: { label: string; weekIdx: number }[] = []; let lastMonth = -1; let weekIdx = 0; const cursor = new Date(startDate); while (cursor <= today) { const d = cursor.getDay(); // 0=Sun ... 6=Sat if (d === 0 && cells.length > 0) weekIdx++; const cellDate = new Date(cursor); const key = dateToKey(cellDate); cells.push({ date: cellDate, count: countMap.get(key) ?? 0, weekIdx, dayIdx: d }); // Track month labels (on the first Sunday-row cell of each new month) if (cellDate.getMonth() !== lastMonth && d === 0) { lastMonth = cellDate.getMonth(); months.push({ label: cellDate.toLocaleDateString(undefined, { month: 'short' }), weekIdx }); } cursor.setDate(cursor.getDate() + 1); } return { grid: cells, monthLabels: months, totalEvents: total, maxDailyCount: maxCount, totalWeeks: weekIdx + 1, }; }, [timestamps]); // Dynamic cell size: fill available width (parent is ~100%). // We use a viewBox + 100% width so SVG scales to fit container. const DAY_LABEL_WIDTH = 28; const cellSize = 11; const svgWidth = DAY_LABEL_WIDTH + totalWeeks * (cellSize + CELL_GAP) + CELL_GAP; const svgHeight = DAYS_PER_WEEK * (cellSize + CELL_GAP) + 22; if (loading) { return (

{t('memory.ingestionActivity')}

); } return (

{t('memory.ingestionActivity')}

{totalEvents} {totalEvents !== 1 ? t('memory.events') : t('memory.event')}{' '} {t('memory.overTheLast')} {MONTHS} {t('memory.months')} {maxDailyCount > 0 && ( <> {' '} · {t('memory.peak')}: {maxDailyCount} {t('memory.perDay')} )}

{t('memory.less')} {INTENSITY_COLORS.map((color, i) => (
))} {t('memory.more')}
{/* Day labels */} {dayLabels(t).map((label, i) => label ? ( {label} ) : null )} {/* Month labels */} {monthLabels.map((m, i) => ( {m.label} ))} {/* Cells */} {grid.map((cell, i) => { const x = DAY_LABEL_WIDTH + cell.weekIdx * (cellSize + CELL_GAP); const y = 18 + cell.dayIdx * (cellSize + CELL_GAP); const intensity = getIntensity(cell.count); return ( { const rect = (e.target as SVGRectElement).getBoundingClientRect(); setHoveredCell({ date: cell.date, count: cell.count, x: rect.left + rect.width / 2, y: rect.top, }); }} onMouseLeave={() => setHoveredCell(null)} /> ); })} {/* Tooltip */} {hoveredCell && (
{hoveredCell.count} {hoveredCell.count !== 1 ? t('memory.events') : t('memory.event')} {' '} {t('memory.on')} {formatDate(hoveredCell.date)}
)}
); }