106 lines
3.4 KiB
TypeScript
106 lines
3.4 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
|
||
export interface DayValue {
|
||
date: string; // YYYY-MM-DD
|
||
value: number;
|
||
}
|
||
|
||
interface Props {
|
||
data: DayValue[];
|
||
days?: number;
|
||
}
|
||
|
||
function toLocalDateStr(d: Date): string {
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
|
||
export default function HistoryChart({ data, days = 90 }: Props) {
|
||
const [hovered, setHovered] = useState<number | null>(null);
|
||
|
||
const byDate = new Map(data.map(d => [d.date, d.value]));
|
||
const today = new Date();
|
||
|
||
const filled = Array.from({ length: days }, (_, i) => {
|
||
const d = new Date(today);
|
||
d.setDate(d.getDate() - (days - 1 - i));
|
||
const date = toLocalDateStr(d);
|
||
return { date, value: byDate.get(date) ?? 0, month: d.getMonth() };
|
||
});
|
||
|
||
const maxVal = Math.max(...filled.map(d => d.value), 1);
|
||
const CHART_H = 40;
|
||
const BAR_UNIT = 100 / days;
|
||
|
||
// Show a month label whenever the month changes
|
||
const monthLabels: { label: string; pct: number }[] = [];
|
||
filled.forEach((d, i) => {
|
||
if (i === 0 || d.month !== filled[i - 1].month) {
|
||
monthLabels.push({ label: MONTH_ABBR[d.month], pct: (i / days) * 100 });
|
||
}
|
||
});
|
||
|
||
const hoveredDay = hovered !== null ? filled[hovered] : null;
|
||
|
||
return (
|
||
<div>
|
||
<div className="relative">
|
||
{/* Tooltip */}
|
||
{hoveredDay && hovered !== null && (
|
||
<div
|
||
className="absolute bottom-0 mb-1 text-xs bg-ctp-surface1 text-ctp-text rounded-lg px-2 py-1 pointer-events-none shadow border border-ctp-surface2 z-10 whitespace-nowrap -translate-x-1/2"
|
||
style={{ left: `${Math.min(92, Math.max(8, ((hovered + 0.5) / days) * 100))}%` }}
|
||
>
|
||
<span className="font-bold text-ctp-mauve">
|
||
{hoveredDay.value > 0 ? `+${hoveredDay.value}` : '–'}
|
||
</span>
|
||
<span className="text-ctp-subtext1 ml-1.5">{hoveredDay.date}</span>
|
||
</div>
|
||
)}
|
||
|
||
<svg
|
||
viewBox={`0 0 100 ${CHART_H}`}
|
||
className="w-full h-20"
|
||
preserveAspectRatio="none"
|
||
style={{ display: 'block' }}
|
||
>
|
||
{filled.map((d, i) => {
|
||
const barH = d.value > 0 ? Math.max(1.5, (d.value / maxVal) * (CHART_H - 2)) : 1;
|
||
const x = i * BAR_UNIT + BAR_UNIT * 0.1;
|
||
const w = BAR_UNIT * 0.8;
|
||
const y = d.value > 0 ? CHART_H - barH : CHART_H - 1;
|
||
return (
|
||
<rect
|
||
key={d.date}
|
||
x={x} y={y}
|
||
width={w} height={barH}
|
||
rx={0.4}
|
||
className={d.value > 0 ? 'fill-ctp-mauve' : 'fill-ctp-surface1'}
|
||
style={{ opacity: hovered === i ? 0.6 : 1 }}
|
||
onMouseEnter={() => setHovered(i)}
|
||
onMouseLeave={() => setHovered(null)}
|
||
/>
|
||
);
|
||
})}
|
||
</svg>
|
||
</div>
|
||
|
||
{/* Month labels */}
|
||
<div className="relative h-4 mt-0.5 select-none">
|
||
{monthLabels.map(({ label, pct }) => (
|
||
<span
|
||
key={`${label}-${pct}`}
|
||
className="absolute text-ctp-overlay0 leading-none pointer-events-none"
|
||
style={{ left: `${pct}%`, fontSize: '10px' }}
|
||
>
|
||
{label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|