Web app ready

This commit is contained in:
2026-06-06 17:08:04 +02:00
parent ff187a5bd4
commit 3e127afbae
39 changed files with 2622 additions and 123 deletions
+105
View File
@@ -0,0 +1,105 @@
'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>
);
}