cube-tui 0.1.9

Terminal UI timer and session manager for speedcubing, with optional web dashboard and BLE (GAN) timer support.
import { X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { History, Time, WcaEvent } from "../types/types";
import { Modifier, WCA_EVENT_NAMES } from "../types/types";
import {
	computeAo,
	computeBest,
	computeBestAo,
	computeMean,
	computePercentile,
	effectiveMs,
	formatMillis,
	formatTime,
} from "../utils/format";
import { HistoryModule } from "./modules/HistoryModule";
import { RecordsModule } from "./modules/RecordsModule";
import { SessionStatsModule } from "./modules/SessionStatsModule";
import { TimeTrendModule } from "./modules/TimeTrendModule";
import { TimerDisplay } from "./TimerDisplay.tsx";

function ModalStatCard({ label, value }: { label: string; value: string }) {
	const dim = value === "—" || value === "DNF";
	return (
		<div className="bg-cell border border-border px-3 py-2.5 flex flex-col gap-1">
			<span className="text-[10px] uppercase tracking-[0.12em] text-muted font-semibold">
				{label}
			</span>
			<span
				className={`font-mono text-sm font-semibold tabular-nums ${dim ? "text-muted" : "text-ink"}`}
			>
				{value}
			</span>
		</div>
	);
}

function formatDateTime(unix_ms: number): string {
	if (!unix_ms) return "—";
	return new Date(unix_ms).toLocaleString("en", {
		dateStyle: "medium",
		timeStyle: "short",
	});
}

function modifierLabel(modifier: Modifier): string {
	switch (modifier) {
		case Modifier.None:
			return "None";
		case Modifier.PlusTwo:
			return "+2";
		case Modifier.DNF:
			return "DNF";
	}
}

function formatDelta(ms: number): string {
	const sign = ms >= 0 ? "+" : "-";
	return `${sign}${formatMillis(Math.abs(ms))}`;
}

interface TimeDetailsModalProps {
	time: Time;
	solveIndex: number;
	isBest: boolean;
	bestMs: number | null;
	onClose: () => void;
}

function TimeDetailsModal({
	time,
	solveIndex,
	isBest,
	bestMs,
	onClose,
}: TimeDetailsModalProps) {
	const rawMs = time.timestamp_in_millis;
	const finalMs = effectiveMs(time);
	const deltaVsBest =
		finalMs !== null && bestMs !== null ? finalMs - bestMs : null;

	return (
		<button
			type="button"
			className="fixed inset-0 z-50 bg-matte/80 backdrop-blur-sm flex items-center justify-center px-4 cursor-default"
			onClick={onClose}
			onKeyDown={(e) => {
				if (e.key === "Enter" || e.key === " ") onClose();
			}}
		>
			<div
				className="w-full max-w-xl border border-border bg-surface shadow-lg"
				onClick={(e) => e.stopPropagation()}
				onKeyDown={(e) => e.stopPropagation()}
				role="dialog"
				aria-modal="true"
				aria-label="Solve details"
			>
				<div className="flex items-center justify-between px-5 py-4 border-b border-border">
					<div>
						<p className="text-[10px] uppercase tracking-[0.12em] text-muted font-semibold">
							Solve {solveIndex}
						</p>
						<h3 className="text-lg font-mono font-semibold text-ink mt-1">
							{formatTime(time)}
						</h3>
					</div>
					<button
						type="button"
						onClick={onClose}
						aria-label="Close details"
						className="border border-border text-muted hover:text-ink hover:border-border-hover p-1.5 transition-colors"
					>
						<X size={16} strokeWidth={2} />
					</button>
				</div>

				<div className="px-5 py-4 grid grid-cols-2 gap-2">
					<ModalStatCard label="Event" value={WCA_EVENT_NAMES[time.event]} />
					<ModalStatCard label="Penalty" value={modifierLabel(time.modifier)} />
					<ModalStatCard label="Raw" value={formatMillis(rawMs)} />
					<ModalStatCard label="Final" value={formatTime(time)} />
					<ModalStatCard label="Session Best" value={isBest ? "Yes" : "No"} />
					<ModalStatCard
						label="Δ vs Best"
						value={deltaVsBest === null ? "—" : formatDelta(deltaVsBest)}
					/>
				</div>

				<div className="px-5 pb-5">
					<p className="text-[10px] uppercase tracking-[0.12em] text-muted font-semibold mb-1.5">
						Solved At
					</p>
					<p className="text-sm text-muted mb-4">
						{formatDateTime(time.solved_at_unix_ms)}
					</p>

					<p className="text-[10px] uppercase tracking-[0.12em] text-muted font-semibold mb-1.5">
						Scramble
					</p>
					<div className="border border-border bg-cell px-3 py-2.5 text-xs text-muted font-mono leading-relaxed break-all">
						{time.scramble || "—"}
					</div>
				</div>
			</div>
		</button>
	);
}

interface TimesColumnProps {
	history: History;
}

export default function TimesColumn({ history }: TimesColumnProps) {
	const [selectedIdx, setSelectedIdx] = useState<number | null>(null);

	const times = history.times;

	const {
		bestMs,
		ao5,
		ao12,
		ao50,
		ao100,
		bestAo5,
		bestAo12,
		bestAo50,
		bestAo100,
		meanMs,
		lastSolvePercentile,
		eventLabel,
		reversed,
	} = useMemo(() => {
		const reversed = [...times].reverse();
		const bestMs = computeBest(times);
		const ao5 = computeAo(times, 5);
		const ao12 = computeAo(times, 12);
		const ao50 = computeAo(times, 50);
		const ao100 = computeAo(times, 100);
		const bestAo5 = computeBestAo(times, 5);
		const bestAo12 = computeBestAo(times, 12);
		const bestAo50 = computeBestAo(times, 50);
		const bestAo100 = computeBestAo(times, 100);
		const meanMs = computeMean(times);
		const lastSolvePercentile = computePercentile(times);

		const eventCounts = times.reduce<Partial<Record<WcaEvent, number>>>(
			(acc, t) => {
				acc[t.event] = (acc[t.event] ?? 0) + 1;
				return acc;
			},
			{},
		);
		const mainEvent = (
			Object.entries(eventCounts) as [WcaEvent, number][]
		).sort((a, b) => b[1] - a[1])[0]?.[0];
		const eventLabel = mainEvent ? WCA_EVENT_NAMES[mainEvent] : "Session";

		return {
			bestMs,
			ao5,
			ao12,
			ao50,
			ao100,
			bestAo5,
			bestAo12,
			bestAo50,
			bestAo100,
			meanMs,
			lastSolvePercentile,
			eventLabel,
			reversed,
		};
	}, [times]);

	useEffect(() => {
		function handleEscape(event: KeyboardEvent) {
			if (event.key === "Escape") {
				setSelectedIdx(null);
			}
		}

		window.addEventListener("keydown", handleEscape);
		return () => window.removeEventListener("keydown", handleEscape);
	}, []);

	function handleOpen(i: number) {
		setSelectedIdx(i);
	}

	const selectedTime = selectedIdx !== null ? reversed[selectedIdx] : null;
	const selectedSolveIndex =
		selectedIdx !== null ? times.length - selectedIdx : 0;
	const selectedEffective = selectedTime ? effectiveMs(selectedTime) : null;
	const selectedIsBest =
		selectedEffective !== null && selectedEffective === bestMs;

	return (
		<>
			<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-5">
				{/* Hero: session best */}
				<div className="lg:col-span-5">
					<section className="h-full border border-border bg-surface p-5 sm:p-6 flex flex-col justify-between animate-fade-in-up">
						<div>
							<div className="flex items-center gap-2 mb-4">
								<span className="text-xs font-semibold text-accent uppercase tracking-[0.08em]">
									{eventLabel}
								</span>
								<span className="text-xs text-muted">·</span>
								<span className="text-xs text-muted font-mono tabular-nums">
									{times.length} solve{times.length !== 1 ? "s" : ""}
								</span>
							</div>
							<TimerDisplay
								ms={bestMs}
								label="Session best"
								size="hero"
								animate={true}
							/>
						</div>
						<div className="mt-6 pt-4 border-t border-border flex items-center justify-between">
							<p className="text-xs text-muted">
								Last solve:{" "}
								<span className="font-mono tabular-nums text-ink">
									{times.length > 0 ? formatTime(times[times.length - 1]) : "—"}
								</span>
							</p>
							{lastSolvePercentile !== null && (
								<p className="text-xs text-muted">
									Faster than{" "}
									<span className="font-mono tabular-nums text-accent">
										{lastSolvePercentile}%
									</span>
								</p>
							)}
						</div>
					</section>
				</div>

				{/* Stats grid */}
				<div className="lg:col-span-7">
					<SessionStatsModule
						eventLabel={eventLabel}
						timesCount={times.length}
						bestMs={bestMs}
						ao5={ao5}
						ao12={ao12}
						ao50={ao50}
						ao100={ao100}
						meanMs={meanMs}
					/>
				</div>

				{/* Records */}
				<div className="lg:col-span-4">
					<RecordsModule
						bestSingle={bestMs}
						bestAo5={bestAo5}
						bestAo12={bestAo12}
						bestAo50={bestAo50}
						bestAo100={bestAo100}
					/>
				</div>

				{/* Time trend */}
				<div className="lg:col-span-8">
					<TimeTrendModule times={times} />
				</div>

				{/* History */}
				<div className="lg:col-span-12">
					<HistoryModule
						times={times}
						bestMs={bestMs}
						onOpenSolve={handleOpen}
					/>
				</div>
			</div>

			{selectedTime && (
				<TimeDetailsModal
					time={selectedTime}
					solveIndex={selectedSolveIndex}
					isBest={selectedIsBest}
					bestMs={bestMs}
					onClose={() => setSelectedIdx(null)}
				/>
			)}
		</>
	);
}