cube-tui 0.1.7

Terminal UI timer and session manager for speedcubing, with optional web dashboard and BLE (GAN) timer support.
import { Hash } from "lucide-react";
import type { Time } from "../../types/types";
import { Modifier, WCA_EVENT_NAMES } from "../../types/types";
import { effectiveMs, formatDate, formatTime } from "../../utils/format";

interface TimeRowProps {
	index: number;
	time: Time;
	isBest: boolean;
	onOpen: () => void;
	animationDelay: number;
}

function TimeRow({
	index,
	time,
	isBest,
	onOpen,
	animationDelay,
}: TimeRowProps) {
	const isDnf = time.modifier === Modifier.DNF;
	const isPlusTwo = time.modifier === Modifier.PlusTwo;

	const timeColor = isDnf
		? "text-red-400"
		: isBest
			? "text-accent"
			: isPlusTwo
				? "text-amber-400"
				: "text-text";

	return (
		<button
			type="button"
			className={`
                w-full text-left
                border-b border-border/40 cursor-pointer
                transition-colors duration-150
                hover:bg-raised/70
                animate-fade-in-up
            `}
			style={{
				animationDelay: `${animationDelay}ms`,
				animationFillMode: "both",
			}}
			onClick={onOpen}
			onKeyDown={(e) => {
				if (e.key === "Enter" || e.key === " ") onOpen();
			}}
		>
			<div className="flex items-center gap-3 px-5 py-2.5">
				<span className="w-8 text-right font-mono text-xs text-text-dim shrink-0 select-none">
					{index}
				</span>

				<span
					className={`font-mono text-sm font-medium tabular-nums w-36 shrink-0 ${timeColor}`}
				>
					{formatTime(time)}
					{isBest && (
						<span className="ml-2 text-[9px] font-sans font-bold uppercase tracking-widest text-accent/60">
							pb
						</span>
					)}
				</span>

				<span className="hidden sm:inline-flex text-[10px] px-1.5 py-0.5 rounded-md bg-btn-bg border border-border text-text-dim font-medium shrink-0">
					{WCA_EVENT_NAMES[time.event]}
				</span>

				<div className="flex-1" />

				<span className="text-xs text-text-dim tabular-nums">
					{formatDate(time.solved_at_unix_ms)}
				</span>
			</div>
		</button>
	);
}

interface HistoryModuleProps {
	times: Time[];
	bestMs: number | null;
	onOpenSolve: (reverseIndex: number) => void;
}

export function HistoryModule({
	times,
	bestMs,
	onOpenSolve,
}: HistoryModuleProps) {
	const reversed = [...times].reverse();

	return (
		<section className="rounded-xl border border-border bg-surface overflow-hidden animate-fade-in-up shadow-lg">
			<div className="flex items-center gap-3 px-5 py-2 border-b border-border bg-raised/40">
				<span className="w-8 text-right">
					<Hash size={10} className="ml-auto text-text-dim" />
				</span>
				<span className="text-[10px] uppercase tracking-widest text-text-dim font-semibold w-36 shrink-0">
					Time
				</span>
				<span className="hidden sm:block text-[10px] uppercase tracking-widest text-text-dim font-semibold">
					Event
				</span>
				<div className="flex-1" />
				<span className="text-[10px] uppercase tracking-widest text-text-dim font-semibold">
					Date
				</span>
				<span className="w-3" />
			</div>

			{times.length === 0 ? (
				<div className="py-20 text-center text-text-muted text-sm">
					No solves recorded.
				</div>
			) : (
				<div className="overflow-y-auto max-h-[58vh]">
					{reversed.map((time, i) => {
						const ms = effectiveMs(time);
						const isBest = ms !== null && ms === bestMs;
						return (
							<TimeRow
								key={time.solved_at_unix_ms}
								index={times.length - i}
								time={time}
								isBest={isBest}
								onOpen={() => onOpenSolve(i)}
								animationDelay={Math.min(i * 18, 400)}
							/>
						);
					})}
				</div>
			)}
		</section>
	);
}