Cronómetro de alta precisión WCA y Progressive Web App diseñado para la comunidad de Speedcubing. Construido bajo los principios de Clean Architecture en un workspace modular de Rust, persistencia ACID en SQLite y una interfaz reactiva e hipermedia sin la sobrecarga de un SPA tradicional.
Rust 2024 con Axum 0.8 y Tokio. Workspace modular desacoplado (Domain, DB, API).
SQLite optimizado con Sqlx, WAL mode (Write-Ahead Logging) y Window Functions.
HTMX para swaps de fragmentos en el servidor y Alpine.js para estados reactivos en el cliente.
Generación de scrambles con 'twips' (Uniform Random State) y cálculo Ao5 con corte del 5%.
En el Speedcubing profesional (resolución a ciegas y a velocidad del Cubo de Rubik), las milésimas de segundo marcan la diferencia. La mayoría de cronómetros web convencionales presentan tres problemas críticos:
setInterval en JavaScript genera retrasos e irregularidades cuando el hilo principal de renderizado está saturado.
✔ La solución: Un backend nativo ultrarrápido en Rust compilado a binario estático, SQLite en modo WAL para escritura inmediata de cada solve, y un frontend ultraligero guiado por hipermedios (HTMX) y PWA instalable con Service Worker para funcionamiento 100% offline.
El proyecto está organizado como un Cargo Workspace desacoplado en tres capas siguiendo los patrones de Clean Architecture y Domain-Driven Design (DDD):
El núcleo puro del sistema, totalmente agnóstico a la base de datos o el transporte web.
Implementa los modelos del dominio (Solve, enum Penalty: None, +2, DNF), la lógica de generación de estados aleatorios oficiales de la WCA para 2x2, 3x3 y 4x4 usando la librería twips, y el algoritmo de cálculo de estadísticas (Average of N y Average of 5) con la regla de corte oficial del 5% superior e inferior redondeado hacia arriba.
Encapsula la persistencia mediante sqlx sobre SQLite. Emplea un pool de conexiones configurado con journal_mode = WAL (permite lecturas y escrituras concurrentes sin bloqueos de archivo) y synchronous = NORMAL.
Resuelve la numeración exacta e inmutable de los solves mediante una Common Table Expression (CTE) con la Window Function:
ROW_NUMBER() OVER (ORDER BY id ASC) as solve_number, asegurando que la numeración no sufra saltos al paginar con LIMIT.
Punto de entrada de la aplicación con axum. Implementa endpoints REST y endpoints de hipermedia que devuelven fragmentos HTML optimizados para HTMX.
Para proteger el reactor de hilos asíncronos de Tokio ante la complejidad combinatoria del generador de mezclas de twips, delega el cálculo a hilos bloqueantes dedicados mediante tokio::task::spawn_blocking.
Cálculo de Average of N (AoN) con descarte del 5% WCA y gestión de penalizaciones DNF y +2:
// Regla oficial WCA: descartar el 5% superior y 5% inferior
let trim = ((n as f64) * 0.05).ceil() as usize;
if dnf_count > trim {
return AverageResult::Dnf;
}
times.sort_unstable();
// Excluimos los peores y mejores según el corte
let valid_times = ×[trim..(n - trim)];
let sum: i64 = valid_times.iter().map(|&t| t as i64).sum();
let divisor = (n - 2 * trim) as i64;
AverageResult::Ok((sum / divisor) as i32) Consulta optimizada con Window Functions para numeración secuencial global en SQLite:
WITH numbered AS (
SELECT
id,
event_code,
time_ms,
penalty,
scramble,
ROW_NUMBER() OVER (ORDER BY id ASC) as solve_number
FROM solves
WHERE event_code = ?1
)
SELECT id, event_code, time_ms, penalty, scramble, solve_number
FROM numbered
ORDER BY id DESC
LIMIT ?2 Generación asíncrona de mezclas sin bloquear el event loop de Tokio:
/// GET /scramble/:event
async fn get_scramble_handler(Path(event): Path<String>) -> Result<String, (StatusCode, String)> {
// Despacho a hilo de cálculo para operaciones CPU-bound intensivas de twips
tokio::task::spawn_blocking(move || generate_wca_scramble(&event))
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|e| (StatusCode::BAD_REQUEST, e))
} Durante la evolución del proyecto se llevó a cabo una auditoría integral de arquitectura que identificó y resolvió los siguientes puntos clave:
Al consultar 50 filas con LIMIT 50, numerar en base a solves.len() - i provocaba que cualquier solve a partir del #51 mantuviera la fila superior en "#50". Se corrigió delegando la numeración al motor SQL mediante ROW_NUMBER() OVER (ORDER BY id ASC).
La exploración combinatoria del estado del cubo en twips se aisló con tokio::task::spawn_blocking, asegurando que el servidor pueda responder peticiones concurrentes a milisegundos de latencia.
En la generación de fragmentos HTML para HTMX se implementó una función escape_html completa que sustituye caracteres como <, >, " y '.
Uso de DATABASE_URL configurable por variable de entorno para montar volúmenes persistentes en /app/data/speedcube_data.db bajo contenedores Alpine/Debian ultraligeros.