mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
258 lines
8.3 KiB
TypeScript
258 lines
8.3 KiB
TypeScript
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<string, number>();
|
|
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 (
|
|
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-5">
|
|
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-3">
|
|
{t('memory.ingestionActivity')}
|
|
</h3>
|
|
<div className="h-28 rounded-lg bg-stone-200 dark:bg-neutral-800 animate-pulse" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-5">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
|
{t('memory.ingestionActivity')}
|
|
</h3>
|
|
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
|
|
{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')}
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 text-[10px] text-stone-500 dark:text-neutral-400">
|
|
<span>{t('memory.less')}</span>
|
|
{INTENSITY_COLORS.map((color, i) => (
|
|
<div
|
|
key={i}
|
|
className="w-[10px] h-[10px] rounded-[2px]"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
))}
|
|
<span>{t('memory.more')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<svg
|
|
width="100%"
|
|
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
|
preserveAspectRatio="xMinYMin meet"
|
|
className="block">
|
|
{/* Day labels */}
|
|
{dayLabels(t).map((label, i) =>
|
|
label ? (
|
|
<text
|
|
key={i}
|
|
x={0}
|
|
y={22 + i * (cellSize + CELL_GAP) + cellSize * 0.75}
|
|
fontSize={9}
|
|
fill="rgba(0,0,0,0.4)"
|
|
style={{ userSelect: 'none' }}>
|
|
{label}
|
|
</text>
|
|
) : null
|
|
)}
|
|
|
|
{/* Month labels */}
|
|
{monthLabels.map((m, i) => (
|
|
<text
|
|
key={i}
|
|
x={DAY_LABEL_WIDTH + m.weekIdx * (cellSize + CELL_GAP)}
|
|
y={12}
|
|
fontSize={9}
|
|
fill="rgba(0,0,0,0.4)"
|
|
style={{ userSelect: 'none' }}>
|
|
{m.label}
|
|
</text>
|
|
))}
|
|
|
|
{/* 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 (
|
|
<rect
|
|
key={i}
|
|
x={x}
|
|
y={y}
|
|
width={cellSize}
|
|
height={cellSize}
|
|
rx={2}
|
|
fill={INTENSITY_COLORS[intensity]}
|
|
stroke={
|
|
hoveredCell?.date.getTime() === cell.date.getTime()
|
|
? 'rgba(255,255,255,0.4)'
|
|
: 'transparent'
|
|
}
|
|
strokeWidth={1}
|
|
style={{ cursor: 'pointer', transition: 'fill 0.1s' }}
|
|
onMouseEnter={e => {
|
|
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)}
|
|
/>
|
|
);
|
|
})}
|
|
</svg>
|
|
|
|
{/* Tooltip */}
|
|
{hoveredCell && (
|
|
<div
|
|
className="fixed z-50 px-2 py-1 rounded-md bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 text-[11px] text-stone-900 dark:text-neutral-100 shadow-lg pointer-events-none"
|
|
style={{ left: hoveredCell.x, top: hoveredCell.y - 32, transform: 'translateX(-50%)' }}>
|
|
<span className="font-medium">
|
|
{hoveredCell.count} {hoveredCell.count !== 1 ? t('memory.events') : t('memory.event')}
|
|
</span>{' '}
|
|
<span className="text-stone-400 dark:text-neutral-500">
|
|
{t('memory.on')} {formatDate(hoveredCell.date)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|