cube-tui 0.1.8

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

function ModalStatCard({ label, value }: { label: string; value: string }) {
    const dim = value === "—" || value === "DNF";
    return (
        <div className="bg-raised rounded-lg px-3 py-2.5 border border-border flex flex-col gap-1">
            <span className="text-[10px] uppercase tracking-widest text-text-dim font-semibold">{label}</span>
            <span className={`font-mono text-sm font-semibold tabular-nums ${dim ? "text-text-muted" : "text-text"}`}>{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-black/55 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 rounded-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-xs uppercase tracking-widest text-text-dim font-semibold">Solve {solveIndex}</p>
                        <h3 className="text-base font-semibold text-text mt-1">{formatTime(time)}</h3>
                    </div>
                    <button
                        type="button"
                        onClick={onClose}
                        aria-label="Close details"
                        className="rounded-lg border border-border text-text-muted hover:text-text 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-widest text-text-dim font-semibold mb-1.5">Solved At</p>
                    <p className="text-sm text-text-muted mb-4">{formatDateTime(time.solved_at_unix_ms)}</p>

                    <p className="text-[10px] uppercase tracking-widest text-text-dim font-semibold mb-1.5">Scramble</p>
                    <div className="rounded-lg border border-border bg-raised px-3 py-2.5 text-xs text-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 reversed = [...times].reverse();

    const bestMs = computeBest(times);
    const ao5 = computeAo(times, 5);
    const ao12 = computeAo(times, 12);
    const meanMs = computeMean(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";

    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 xl:grid-cols-12 gap-4">
                <div className="xl:col-span-4">
                    <SessionStatsModule eventLabel={eventLabel} timesCount={times.length} bestMs={bestMs} ao5={ao5} ao12={ao12} meanMs={meanMs} />
                </div>

                <div className="xl:col-span-8">
                    <TimeTrendModule times={times} />
                </div>

                <div className="xl: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)}
                />
            )}
        </>
    );
}