cube-tui 0.1.9

Terminal UI timer and session manager for speedcubing, with optional web dashboard and BLE (GAN) timer support.
import { memo, useMemo } from "react";
import {
	CartesianGrid,
	Line,
	LineChart,
	ResponsiveContainer,
	Tooltip,
	XAxis,
	YAxis,
} from "recharts";
import type { Time } from "../../types/types";
import { effectiveMs, formatMillis } from "../../utils/format";

interface TimeTrendModuleProps {
	times: Time[];
}

const MAX_CHART_POINTS = 100;

function downsample<T>(items: T[], target: number): T[] {
	if (items.length <= target) return items;
	const step = Math.ceil(items.length / target);
	const sampled: T[] = [];
	for (let i = 0; i < items.length; i += step) {
		sampled.push(items[i]);
	}
	// Always include the last point so the trend reaches the present solve.
	if (sampled[sampled.length - 1] !== items[items.length - 1]) {
		sampled.push(items[items.length - 1]);
	}
	return sampled;
}

function TimeTrendModuleInner({ times }: TimeTrendModuleProps) {
	const { chartData, validTimes, dnfCount, minMs, maxMs, yMin, yMax } =
		useMemo(() => {
			const allPoints = times.map((time, index) => ({
				solve: index + 1,
				ms: effectiveMs(time),
			}));

			const chartData = downsample(allPoints, MAX_CHART_POINTS);
			const validTimes = allPoints
				.map((point) => point.ms)
				.filter((ms): ms is number => ms !== null);
			const dnfCount = allPoints.length - validTimes.length;

			if (validTimes.length === 0) {
				return {
					chartData,
					validTimes,
					dnfCount,
					minMs: null,
					maxMs: null,
					yMin: 0,
					yMax: 0,
				};
			}

			const minMs = Math.min(...validTimes);
			const maxMs = Math.max(...validTimes);
			const rangePadding = Math.max(1000, Math.round((maxMs - minMs) * 0.08));
			const yMin = Math.max(0, minMs - rangePadding);
			const yMax = maxMs + rangePadding;

			return { chartData, validTimes, dnfCount, minMs, maxMs, yMin, yMax };
		}, [times]);

	if (validTimes.length === 0) {
		return (
			<section className="h-full border border-border bg-surface animate-fade-in-up p-5 flex flex-col">
				<p className="text-[10px] uppercase tracking-[0.14em] text-muted font-semibold mb-2">
					Time trend
				</p>
				<p className="text-sm text-muted">No valid solves to chart.</p>
			</section>
		);
	}

	return (
		<section className="h-full border border-border bg-surface animate-fade-in-up p-5 flex flex-col">
			<div className="flex items-center justify-between mb-3">
				<p className="text-[10px] uppercase tracking-[0.14em] text-muted font-semibold">
					Time trend
				</p>
				<span className="text-xs text-muted">
					<span className="font-mono tabular-nums">{times.length}</span> solve
					{times.length === 1 ? "" : "s"} ·{" "}
					<span className="font-mono tabular-nums">{dnfCount}</span> DNF
					{chartData.length < times.length && (
						<>
							{" · "}
							<span className="font-mono tabular-nums">{chartData.length}</span>{" "}
							shown
						</>
					)}
				</span>
			</div>

			<div className="flex-1 min-h-0">
				<ResponsiveContainer width="100%" height="100%">
					<LineChart
						data={chartData}
						margin={{ top: 8, right: 8, bottom: 8, left: 8 }}
					>
						<CartesianGrid
							stroke="var(--border)"
							strokeDasharray="3 3"
							vertical={false}
						/>
						<XAxis
							dataKey="solve"
							tick={{
								fill: "var(--muted)",
								fontSize: 10,
								fontFamily: "var(--font-mono)",
							}}
							tickLine={false}
							axisLine={{ stroke: "var(--border)" }}
							label={{
								value: "Solve #",
								position: "insideBottom",
								offset: -4,
								fill: "var(--muted)",
								fontSize: 10,
								fontFamily: "var(--font-mono)",
							}}
						/>
						<YAxis
							domain={[yMin, yMax]}
							tick={{
								fill: "var(--muted)",
								fontSize: 10,
								fontFamily: "var(--font-mono)",
							}}
							tickLine={false}
							axisLine={{ stroke: "var(--border)" }}
							tickFormatter={(value) => formatMillis(value as number)}
							width={64}
						/>
						<Tooltip
							cursor={{ stroke: "var(--border-hover)", strokeWidth: 1 }}
							contentStyle={{
								background: "var(--surface)",
								border: "1px solid var(--border)",
								borderRadius: 0,
								color: "var(--ink)",
								fontFamily: "var(--font-mono)",
								fontSize: 12,
							}}
							formatter={(value) => formatMillis(value as number)}
							labelFormatter={(label) => `Solve ${label}`}
						/>
						<Line
							type="monotone"
							dataKey="ms"
							connectNulls={false}
							stroke="var(--accent)"
							strokeWidth={2}
							dot={false}
							activeDot={{ r: 4, fill: "var(--accent)" }}
							isAnimationActive={false}
						/>
					</LineChart>
				</ResponsiveContainer>
			</div>

			<div className="mt-2 flex items-center justify-between text-[10px] text-muted font-mono tabular-nums">
				<span>{formatMillis(minMs!)}</span>
				<span>{formatMillis(maxMs!)}</span>
			</div>
		</section>
	);
}

export const TimeTrendModule = memo(TimeTrendModuleInner);