From b4e7a1eb8d6a8933259fb5c820c163f9d15a24b0 Mon Sep 17 00:00:00 2001 From: Ander Varela Date: Sat, 19 Sep 2026 16:18:39 +0200 Subject: [PATCH 01/14] =?UTF-8?q?feat(front):=20a=C3=B1ade=20las=20piezas?= =?UTF-8?q?=20base=20del=20resumen=20de=20evoluci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trend (átomo) dibuja la línea de una serie, un punto por ejecución: escala del mínimo al máximo de la propia serie (enseña la forma de la evolución, no el valor, que ya dice el número de al lado), la referencia punteada del primer valor, trazo discontinuo para datos que no son de verdad, y cada punto con su detalle al pasar el ratón. SegmentedControl (radios nativos), ChangePill (la flecha dice hacia dónde se movió el valor y el color si eso es bueno o malo) y MetricTrend (valor actual, cambio desde el principio y línea) son moléculas construidas con ellas. Iconos arrowUp y minus, y tono warn en Badge. changeTone y shortDate pasan a lib/ui: el tono de un cambio lo comparten la comparativa de ejecuciones y el resumen, y ya no vive duplicado en ComparePanel.helper. --- .../components/atoms/Badge/Badge.module.css | 1 + frontend/components/atoms/Badge/Badge.tsx | 2 +- frontend/components/atoms/Icon/Icon.tsx | 4 +- .../components/atoms/Trend/Trend.helper.ts | 54 +++++++++++++++++++ .../components/atoms/Trend/Trend.module.css | 19 +++++++ frontend/components/atoms/Trend/Trend.tsx | 35 ++++++++++++ frontend/components/atoms/Trend/index.ts | 2 + frontend/components/atoms/index.ts | 2 + .../molecules/ChangePill/ChangePill.tsx | 17 ++++++ .../components/molecules/ChangePill/index.ts | 1 + .../MetricTrend/MetricTrend.module.css | 19 +++++++ .../molecules/MetricTrend/MetricTrend.tsx | 48 +++++++++++++++++ .../components/molecules/MetricTrend/index.ts | 2 + .../SegmentedControl.module.css | 31 +++++++++++ .../SegmentedControl/SegmentedControl.tsx | 35 ++++++++++++ .../molecules/SegmentedControl/index.ts | 1 + frontend/components/molecules/index.ts | 4 ++ .../ComparePanel/ComparePanel.helper.ts | 10 +--- frontend/lib/ui.ts | 17 ++++++ 19 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 frontend/components/atoms/Trend/Trend.helper.ts create mode 100644 frontend/components/atoms/Trend/Trend.module.css create mode 100644 frontend/components/atoms/Trend/Trend.tsx create mode 100644 frontend/components/atoms/Trend/index.ts create mode 100644 frontend/components/molecules/ChangePill/ChangePill.tsx create mode 100644 frontend/components/molecules/ChangePill/index.ts create mode 100644 frontend/components/molecules/MetricTrend/MetricTrend.module.css create mode 100644 frontend/components/molecules/MetricTrend/MetricTrend.tsx create mode 100644 frontend/components/molecules/MetricTrend/index.ts create mode 100644 frontend/components/molecules/SegmentedControl/SegmentedControl.module.css create mode 100644 frontend/components/molecules/SegmentedControl/SegmentedControl.tsx create mode 100644 frontend/components/molecules/SegmentedControl/index.ts diff --git a/frontend/components/atoms/Badge/Badge.module.css b/frontend/components/atoms/Badge/Badge.module.css index 2de1c01..e8e890f 100644 --- a/frontend/components/atoms/Badge/Badge.module.css +++ b/frontend/components/atoms/Badge/Badge.module.css @@ -12,6 +12,7 @@ } .neutral { color: var(--text-3); } .ok { color: var(--ok); background: var(--ok-fill); } +.warn { color: var(--warn); background: var(--warn-fill); } .bad { color: var(--bad); background: var(--bad-fill-soft); } .oracle { color: var(--oracle); } .synthetic { color: var(--synthetic); } diff --git a/frontend/components/atoms/Badge/Badge.tsx b/frontend/components/atoms/Badge/Badge.tsx index 6bd0a5b..799b49b 100644 --- a/frontend/components/atoms/Badge/Badge.tsx +++ b/frontend/components/atoms/Badge/Badge.tsx @@ -5,7 +5,7 @@ import styles from "./Badge.module.css"; /** `oracle` y `synthetic` van con trama diagonal a propósito: son números que no son de * verdad y tienen que molestar a la vista. */ export function Badge({ tone = "neutral", children }: { - tone?: "neutral" | "oracle" | "synthetic" | "ok" | "bad"; children: ReactNode; + tone?: "neutral" | "oracle" | "synthetic" | "ok" | "warn" | "bad"; children: ReactNode; }) { return {children}; } diff --git a/frontend/components/atoms/Icon/Icon.tsx b/frontend/components/atoms/Icon/Icon.tsx index 444d4cd..a8835af 100644 --- a/frontend/components/atoms/Icon/Icon.tsx +++ b/frontend/components/atoms/Icon/Icon.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; -export type IconName = "check" | "arrowDown" | "crosshair" | "circleDot" | "wave" | "x" | "flag" | "chevronsUpDown" | "sun" | "moon" | "chevronLeft" | "chevronRight" | "arrowLeft"; +export type IconName = "check" | "arrowDown" | "crosshair" | "circleDot" | "wave" | "x" | "flag" | "chevronsUpDown" | "sun" | "moon" | "chevronLeft" | "chevronRight" | "arrowLeft" | "arrowUp" | "minus"; /* Trazos de 16×16. Iconos de línea, sin relleno: ninguno lleva color propio, heredan `currentColor` del que los use. */ @@ -18,6 +18,8 @@ const PATHS: Record = { chevronLeft: , chevronRight: , arrowLeft: , + arrowUp: , + minus: , }; /** Decorativo: el significado lo lleva el texto de al lado, no el icono. */ diff --git a/frontend/components/atoms/Trend/Trend.helper.ts b/frontend/components/atoms/Trend/Trend.helper.ts new file mode 100644 index 0000000..b19e9aa --- /dev/null +++ b/frontend/components/atoms/Trend/Trend.helper.ts @@ -0,0 +1,54 @@ +export type TrendPoint = { value: number | null; title: string }; + +export type TrendGeometry = { + width: number; + height: number; + /** Un tramo por cada racha de valores; un hueco (null) corta la línea. */ + path: string; + dots: Array<{ x: number; y: number; title: string; last: boolean }>; + /** Altura del primer valor: la referencia contra la que se lee el avance. */ + baselineY: number | null; +}; + +const W = 200; +const H = 52; +const PAD_X = 8; +const PAD_Y = 8; +/** Con más puntos que esto los círculos se pisan: solo se marca el último. */ +const MAX_DOTS = 40; + +/** De valores a coordenadas. La escala va del mínimo al máximo de LA SERIE: enseña la forma + * de la evolución, no el valor absoluto (el valor ya lo dice el número de al lado). Una + * serie plana se dibuja en el centro y no como una subida exagerada de nada. */ +export function trendGeometry(points: TrendPoint[]): TrendGeometry { + const values = points.map((p) => p.value).filter((v): v is number => v != null); + const lo = Math.min(...values); + const hi = Math.max(...values); + const span = hi - lo; + + const x = (i: number) => + points.length === 1 ? W / 2 : PAD_X + (i / (points.length - 1)) * (W - PAD_X * 2); + const y = (v: number) => + span === 0 ? H / 2 : PAD_Y + ((hi - v) / span) * (H - PAD_Y * 2); + + let path = ""; + let pen = false; + const dots: TrendGeometry["dots"] = []; + let lastIndex = -1; + points.forEach((p, i) => { if (p.value != null) lastIndex = i; }); + + points.forEach((p, i) => { + if (p.value == null) { pen = false; return; } + path += `${pen ? "L" : "M"}${x(i).toFixed(1)},${y(p.value).toFixed(1)} `; + pen = true; + if (points.length <= MAX_DOTS || i === lastIndex) { + dots.push({ x: x(i), y: y(p.value), title: p.title, last: i === lastIndex }); + } + }); + + const first = values[0]; + return { + width: W, height: H, path: path.trim(), dots, + baselineY: first == null || values.length < 2 ? null : y(first), + }; +} diff --git a/frontend/components/atoms/Trend/Trend.module.css b/frontend/components/atoms/Trend/Trend.module.css new file mode 100644 index 0000000..c7c797b --- /dev/null +++ b/frontend/components/atoms/Trend/Trend.module.css @@ -0,0 +1,19 @@ +.trend { width: 100%; height: 52px; display: block; overflow: visible; } + +.ok { --tone: var(--ok); } +.bad { --tone: var(--bad); } +.muted { --tone: var(--text-3); } + +.line { + fill: none; + stroke: var(--tone); + stroke-width: 2.25; + stroke-linecap: round; + stroke-linejoin: round; +} +.dashed { stroke-dasharray: 5 5; } + +.baseline { stroke: var(--border-hard); stroke-width: 1; stroke-dasharray: 2 4; } + +.dot { fill: var(--surface); stroke: var(--tone); stroke-width: 1.75; } +.last { fill: var(--tone); stroke: var(--surface); stroke-width: 2; } diff --git a/frontend/components/atoms/Trend/Trend.tsx b/frontend/components/atoms/Trend/Trend.tsx new file mode 100644 index 0000000..ef92678 --- /dev/null +++ b/frontend/components/atoms/Trend/Trend.tsx @@ -0,0 +1,35 @@ +import { cx } from "@/lib/cx"; +import { trendGeometry, type TrendPoint } from "./Trend.helper"; +import styles from "./Trend.module.css"; + +type Props = { + points: TrendPoint[]; + /** Cómo se lee la evolución: verde mejora, rojo empeora, gris sin cambio. */ + tone: "ok" | "bad" | "muted"; + /** Trazo discontinuo: son datos que no son de verdad (oracle o sembrados). */ + dashed?: boolean; + /** Lo que dice el gráfico, para quien no lo ve: «Tasa de éxito, de 41 % a 64 %». */ + label: string; +}; + +/** La línea de evolución de una serie, un punto por ejecución, de la más antigua a la más + * reciente. Sin ejes ni cifras: el valor y el cambio los dicen el número y la etiqueta de al + * lado, y cada punto enseña su detalle al pasar el ratón. */ +export function Trend({ points, tone, dashed, label }: Props) { + const g = trendGeometry(points); + return ( + + {g.baselineY != null && ( + + )} + + {g.dots.map((d, i) => ( + + {d.title} + + ))} + + ); +} diff --git a/frontend/components/atoms/Trend/index.ts b/frontend/components/atoms/Trend/index.ts new file mode 100644 index 0000000..6166387 --- /dev/null +++ b/frontend/components/atoms/Trend/index.ts @@ -0,0 +1,2 @@ +export { Trend } from "./Trend"; +export type { TrendPoint } from "./Trend.helper"; diff --git a/frontend/components/atoms/index.ts b/frontend/components/atoms/index.ts index 9f1d842..9605734 100644 --- a/frontend/components/atoms/index.ts +++ b/frontend/components/atoms/index.ts @@ -22,3 +22,5 @@ export { Table, Th, Td, Tr } from "./Table"; export { Sparkline } from "./Sparkline"; export { Logo } from "./Logo"; export { IconButton } from "./IconButton"; +export { Trend } from "./Trend"; +export type { TrendPoint } from "./Trend"; diff --git a/frontend/components/molecules/ChangePill/ChangePill.tsx b/frontend/components/molecules/ChangePill/ChangePill.tsx new file mode 100644 index 0000000..0a354c1 --- /dev/null +++ b/frontend/components/molecules/ChangePill/ChangePill.tsx @@ -0,0 +1,17 @@ +import { Badge, Icon, type IconName } from "../../atoms"; + +const ICON: Record<"up" | "down" | "flat", IconName> = { + up: "arrowUp", down: "arrowDown", flat: "minus", +}; + +/** Cuánto ha cambiado algo. La FLECHA dice hacia dónde se movió el valor y el COLOR dice si + * eso es bueno o malo: un tiempo que baja es una flecha hacia abajo en verde. */ +export function ChangePill({ direction, tone, children }: { + direction: "up" | "down" | "flat"; tone: "ok" | "bad" | "muted"; children: string; +}) { + return ( + + {children} + + ); +} diff --git a/frontend/components/molecules/ChangePill/index.ts b/frontend/components/molecules/ChangePill/index.ts new file mode 100644 index 0000000..19f67f1 --- /dev/null +++ b/frontend/components/molecules/ChangePill/index.ts @@ -0,0 +1 @@ +export { ChangePill } from "./ChangePill"; diff --git a/frontend/components/molecules/MetricTrend/MetricTrend.module.css b/frontend/components/molecules/MetricTrend/MetricTrend.module.css new file mode 100644 index 0000000..17df6f1 --- /dev/null +++ b/frontend/components/molecules/MetricTrend/MetricTrend.module.css @@ -0,0 +1,19 @@ +.row { + display: grid; + grid-template-columns: minmax(130px, 1fr) minmax(130px, 1fr) minmax(150px, 1.3fr); + align-items: center; + gap: 16px 24px; + padding: 18px 0; + border-bottom: 1px solid var(--border-soft); +} +.row:last-child { border-bottom: 0; } + +.now { display: flex; flex-direction: column; gap: 8px; min-width: 0; } +.change { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-width: 0; } +.chart { min-width: 0; } + +/* Estrecho: la línea baja a su propia fila en vez de aplastarse. */ +@media (max-width: 720px) { + .row { grid-template-columns: 1fr 1fr; } + .chart { grid-column: 1 / -1; } +} diff --git a/frontend/components/molecules/MetricTrend/MetricTrend.tsx b/frontend/components/molecules/MetricTrend/MetricTrend.tsx new file mode 100644 index 0000000..2de5a0a --- /dev/null +++ b/frontend/components/molecules/MetricTrend/MetricTrend.tsx @@ -0,0 +1,48 @@ +import { Text, Trend, Value, type TrendPoint } from "../../atoms"; +import { ChangePill } from "../ChangePill"; +import styles from "./MetricTrend.module.css"; + +export type MetricChange = { + direction: "up" | "down" | "flat"; + tone: "ok" | "bad" | "muted"; + /** «+23 puntos». */ + text: string; +}; + +type Props = { + label: string; + value: string; + unit: string; + /** `null` con un solo dato: no hay contra qué comparar. */ + change: MetricChange | null; + /** Qué se está comparando: «desde el primero (41 %)». */ + note: string; + points: TrendPoint[]; + tone: "ok" | "bad" | "muted"; + dashed?: boolean; + chartLabel: string; +}; + +/** Una métrica de una serie: dónde está ahora, cuánto ha cambiado desde el principio y cómo ha + * ido en el camino. Las tres cosas juntas responden a «¿vamos mejor?» de un vistazo. */ +export function MetricTrend({ + label, value, unit, change, note, points, tone, dashed, chartLabel, +}: Props) { + return ( +
+
+ {label} + +
+
+ {change + ? {change.text} + : sin comparación} + {note} +
+
+ +
+
+ ); +} diff --git a/frontend/components/molecules/MetricTrend/index.ts b/frontend/components/molecules/MetricTrend/index.ts new file mode 100644 index 0000000..0ef81cf --- /dev/null +++ b/frontend/components/molecules/MetricTrend/index.ts @@ -0,0 +1,2 @@ +export { MetricTrend } from "./MetricTrend"; +export type { MetricChange } from "./MetricTrend"; diff --git a/frontend/components/molecules/SegmentedControl/SegmentedControl.module.css b/frontend/components/molecules/SegmentedControl/SegmentedControl.module.css new file mode 100644 index 0000000..6cf2cf9 --- /dev/null +++ b/frontend/components/molecules/SegmentedControl/SegmentedControl.module.css @@ -0,0 +1,31 @@ +.group { + display: inline-flex; + padding: 4px; + gap: 2px; + background: var(--row-alt); + border: 1px solid var(--border); + border-radius: var(--radius-pill); +} + +.option { position: relative; cursor: pointer; } +.input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: inherit; } + +.face { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 16px; + font-size: 13.5px; + font-weight: 500; + color: var(--text-3); + border-radius: var(--radius-pill); + white-space: nowrap; +} +.option:hover .face { color: var(--text); } +/* La opción elegida, en tinta, como la pestaña activa de la barra. */ +.input:checked + .face { background: var(--select); color: var(--on-select); } +.input:checked + .face .count { color: inherit; opacity: 0.75; } +.input:focus-visible + .face { outline: 2px solid var(--select); outline-offset: 2px; } + +.count { color: var(--text-4); } +.empty .face { opacity: 0.55; } diff --git a/frontend/components/molecules/SegmentedControl/SegmentedControl.tsx b/frontend/components/molecules/SegmentedControl/SegmentedControl.tsx new file mode 100644 index 0000000..76b5bb9 --- /dev/null +++ b/frontend/components/molecules/SegmentedControl/SegmentedControl.tsx @@ -0,0 +1,35 @@ +import { Text } from "../../atoms"; +import { cx } from "@/lib/cx"; +import styles from "./SegmentedControl.module.css"; + +type Option = { value: string; label: string; count?: number }; + +type Props = { + /** Comparten `name` para ser un solo grupo: las flechas del teclado cambian de opción. */ + name: string; + label: string; + options: Option[]; + value: string; + onChange: (value: string) => void; +}; + +/** Elegir una opción entre pocas, siempre a la vista. Radios nativos: teclado y lector de + * pantalla gratis. `count` dice cuántas ejecuciones hay detrás de cada una. */ +export function SegmentedControl({ name, label, options, value, onChange }: Props) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} diff --git a/frontend/components/molecules/SegmentedControl/index.ts b/frontend/components/molecules/SegmentedControl/index.ts new file mode 100644 index 0000000..d07ea2b --- /dev/null +++ b/frontend/components/molecules/SegmentedControl/index.ts @@ -0,0 +1 @@ +export { SegmentedControl } from "./SegmentedControl"; diff --git a/frontend/components/molecules/index.ts b/frontend/components/molecules/index.ts index 3072670..a1fef70 100644 --- a/frontend/components/molecules/index.ts +++ b/frontend/components/molecules/index.ts @@ -21,3 +21,7 @@ export { NavTab } from "./NavTab"; export { ThemeToggle } from "./ThemeToggle"; export { EpisodePicker } from "./EpisodePicker"; export { EmptyState } from "./EmptyState"; +export { SegmentedControl } from "./SegmentedControl"; +export { ChangePill } from "./ChangePill"; +export { MetricTrend } from "./MetricTrend"; +export type { MetricChange } from "./MetricTrend"; diff --git a/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts b/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts index a68714b..7242ee3 100644 --- a/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts +++ b/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts @@ -2,7 +2,7 @@ import type { Blocker, Run } from "@/lib/supabase"; import { commonSeeds, comparability } from "@/lib/supabase"; import type { FailureBreakdownRow } from "@/api/types"; import { - TASK_TEXT, failureColor, failureText, pct, seconds, signedNumber, + TASK_TEXT, changeTone, failureColor, failureText, pct, seconds, signedNumber, } from "@/lib/ui"; export type ComparePair = { @@ -49,12 +49,6 @@ function newerFirst([x, y]: [Run, Run]): [Run, Run] { return Date.parse(x.started_at) >= Date.parse(y.started_at) ? [x, y] : [y, x]; } -/** Tono de un delta. Un cambio que se redondea a cero no es ni mejora ni empeora. */ -function deltaTone(diff: number, digits: number, lowerIsBetter: boolean): DeltaView["tone"] { - if (Number(Math.abs(diff).toFixed(digits)) === 0) return "muted"; - return (lowerIsBetter ? diff < 0 : diff > 0) ? "ok" : "bad"; -} - /** Resta de dos valores que ya calculó la vista, no una agregación. Si falta uno, se dice. */ function delta(input: { label: string; newer: number | null; base: number | null; scale: number; digits: number; @@ -66,7 +60,7 @@ function delta(input: { const diff = (newer - base) * scale; return { label, value: signedNumber(diff, digits), unit, detail, - tone: deltaTone(diff, digits, lowerIsBetter), + tone: changeTone(diff, digits, lowerIsBetter), }; } diff --git a/frontend/lib/ui.ts b/frontend/lib/ui.ts index fbc62ef..9e808be 100644 --- a/frontend/lib/ui.ts +++ b/frontend/lib/ui.ts @@ -86,6 +86,23 @@ export function signedNumber(n: number, digits = 0): string { return `${sign}${rounded.toFixed(digits)}`; } +/** Cómo se lee un cambio: verde si mejora, rojo si empeora, gris si no cambia. Un cambio que se + * redondea a cero al enseñarlo no es ni lo uno ni lo otro. `lowerIsBetter` para lo que se + * quiere que baje (tiempo, error). */ +export function changeTone( + diff: number, digits: number, lowerIsBetter: boolean, +): "ok" | "bad" | "muted" { + if (Number(Math.abs(diff).toFixed(digits)) === 0) return "muted"; + return (lowerIsBetter ? diff < 0 : diff > 0) ? "ok" : "bad"; +} + +/** «19 sep». */ +export function shortDate(iso: string | null | undefined): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("es-ES", { day: "numeric", month: "short" }) + .replace(".", ""); +} + /** Parte una cantidad ya formateada ("31 mm") en número y unidad, para pintarlas con * tamaños distintos. No convierte nada: solo trocea lo que devuelven mm/pct/seconds. */ export function splitQuantity(formatted: string): { value: string; unit: string } { From 581b61c6d4c397511a4acb4d807a180891dae501 Mon Sep 17 00:00:00 2001 From: Ander Varela Date: Sat, 19 Sep 2026 16:20:03 +0200 Subject: [PATCH 02/14] =?UTF-8?q?feat(front):=20a=C3=B1ade=20la=20tarjeta?= =?UTF-8?q?=20de=20nivel=20y=20la=20secci=C3=B3n=20de=20tarea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LevelCard enseña cómo va un nivel de una tarea: cada métrica con su valor actual, cuánto ha cambiado desde la primera ejecución y su línea, y un veredicto arriba (Mejora, Empeora, Mixto, Sin cambios) que resume cuántas mejoran y cuántas empeoran. TaskSection agrupa una tarjeta por nivel: se compara un nivel consigo mismo a lo largo del tiempo, nunca un nivel con otro (el criterio de éxito es distinto). Las cuatro métricas salen de v_run_summary: tasa de éxito, tiempo de ciclo, margen de estabilidad y error de colocación. Una métrica que la tarea no mide (la estabilidad de la inducción) no aparece, en vez de salir como «—». El cambio es la resta del último valor y el primero (dos números que ya calculó la vista); el color dice si eso es bueno o malo según la métrica (un tiempo o un error que bajan mejoran). Las clases de datos NO se mezclan en una serie: medidos, oracle y sembrados van aparte, con etiqueta y trazo discontinuo los que no son de verdad, y un aviso si la serie mezcla rangos de semillas distintos («se compara suerte, no código»). --- .../organisms/LevelCard/LevelCard.helper.ts | 152 ++++++++++++++++++ .../organisms/LevelCard/LevelCard.module.css | 16 ++ .../organisms/LevelCard/LevelCard.tsx | 44 +++++ .../components/organisms/LevelCard/index.ts | 3 + .../TaskSection/TaskSection.module.css | 13 ++ .../organisms/TaskSection/TaskSection.tsx | 27 ++++ .../components/organisms/TaskSection/index.ts | 2 + frontend/components/organisms/index.ts | 4 + frontend/lib/ui.ts | 5 + 9 files changed, 266 insertions(+) create mode 100644 frontend/components/organisms/LevelCard/LevelCard.helper.ts create mode 100644 frontend/components/organisms/LevelCard/LevelCard.module.css create mode 100644 frontend/components/organisms/LevelCard/LevelCard.tsx create mode 100644 frontend/components/organisms/LevelCard/index.ts create mode 100644 frontend/components/organisms/TaskSection/TaskSection.module.css create mode 100644 frontend/components/organisms/TaskSection/TaskSection.tsx create mode 100644 frontend/components/organisms/TaskSection/index.ts diff --git a/frontend/components/organisms/LevelCard/LevelCard.helper.ts b/frontend/components/organisms/LevelCard/LevelCard.helper.ts new file mode 100644 index 0000000..416d5c6 --- /dev/null +++ b/frontend/components/organisms/LevelCard/LevelCard.helper.ts @@ -0,0 +1,152 @@ +import type { Run } from "@/lib/supabase"; +import { + changeTone, mm, pct, seconds, shortDate, signedMm, signedNumber, splitQuantity, +} from "@/lib/ui"; +import type { TrendPoint } from "../../atoms"; +import type { MetricChange } from "../../molecules"; + +/** De dónde son los números. Las tres clases NO se mezclan en una serie: un oracle (la + * percepción sustituida por poses reales) o un dato sembrado no mide lo mismo que uno medido, + * y una curva que los junta enseña una mejora que no existe. */ +export type DataKind = "measured" | "oracle" | "synthetic"; + +export const KIND_TEXT: Record = { + measured: "Medidos", oracle: "Oracle", synthetic: "Sembrados", +}; + +export function kindOf(run: Run): DataKind { + return run.synthetic ? "synthetic" : run.oracle ? "oracle" : "measured"; +} + +export type MetricView = { + key: string; + label: string; + value: string; + unit: string; + change: MetricChange | null; + note: string; + points: TrendPoint[]; + tone: "ok" | "bad" | "muted"; + chartLabel: string; +}; + +export type Verdict = { tone: "ok" | "warn" | "bad" | "muted"; label: string; detail: string }; + +export type LevelCardView = { + key: string; + title: string; + /** «15 ejecuciones · 19 sep – 21 sep». */ + meta: string; + kind: DataKind; + /** La serie mezcla rangos de semillas distintos: se compara suerte, no código. */ + mixedSeeds: boolean; + verdict: Verdict; + metrics: MetricView[]; +}; + +type MetricDef = { + key: string; + label: string; + pick: (r: Run) => number | null; + lowerIsBetter: boolean; + /** El valor con su unidad, para enseñarlo. Toda conversión pasa por lib/ui. */ + format: (v: number | null) => string; + /** El cambio se cuenta en la unidad con la que se habla de ello. */ + delta: { scale: number; digits: number; unit: string }; + /** Unidad que se muestra junto al valor cuando el formato no la trae en un solo token. */ + unit?: string; +}; + +/** Lo que se mide de cada serie. La flecha «mejor» es una decisión de dominio: un tiempo o un + * error que bajan mejoran; un éxito o un margen que suben también. */ +const METRICS: MetricDef[] = [ + { key: "success", label: "Tasa de éxito", pick: (r) => r.success_rate, lowerIsBetter: false, + format: (v) => pct(v), delta: { scale: 100, digits: 0, unit: "puntos" } }, + { key: "cycle", label: "Tiempo de ciclo", pick: (r) => r.median_cycle_s, lowerIsBetter: true, + format: (v) => seconds(v), delta: { scale: 1, digits: 1, unit: "s" }, unit: "s / paquete" }, + { key: "stability", label: "Margen de estabilidad", pick: (r) => r.median_stability_m, + lowerIsBetter: false, format: (v) => signedMm(v), + delta: { scale: 1000, digits: 0, unit: "mm" } }, + { key: "error", label: "Error de colocación", pick: (r) => r.median_error_xy_m, + lowerIsBetter: true, format: (v) => mm(v), delta: { scale: 1000, digits: 1, unit: "mm" } }, +]; + +const byDate = (a: Run, b: Run) => Date.parse(a.started_at) - Date.parse(b.started_at); + +function metricOf(def: MetricDef, runs: Run[]): MetricView | null { + const values = runs.map(def.pick); + const known = values.map((v, i) => ({ v, i })).filter((p): p is { v: number; i: number } => p.v != null); + // Una métrica que la tarea no mide (la estabilidad de la inducción) no aparece: nada de «—». + if (!known.length) return null; + + const first = known[0].v; + const last = known[known.length - 1].v; + const { value, unit } = splitQuantity(def.format(last)); + + let change: MetricChange | null = null; + if (known.length > 1) { + const diff = (last - first) * def.delta.scale; + const tone = changeTone(diff, def.delta.digits, def.lowerIsBetter); + change = { + tone, + direction: tone === "muted" ? "flat" : diff > 0 ? "up" : "down", + text: `${signedNumber(diff, def.delta.digits)} ${def.delta.unit}`, + }; + } + + const n = runs.length; + return { + key: def.key, + label: def.label, + value, + unit: def.unit ?? unit, + change, + note: change ? `desde el primero (${def.format(first)})` : "un solo dato", + points: runs.map((r, i) => ({ + value: values[i], + title: `${r.git_sha || r.label || "sin commit"} · ${shortDate(r.started_at)} · ` + + `${values[i] == null ? "sin dato" : def.format(values[i])} · ${r.episodes} episodios`, + })), + tone: change?.tone ?? "muted", + chartLabel: change + ? `${def.label}, de ${def.format(first)} a ${def.format(last)} en ${n} ejecuciones` + : `${def.label}, ${def.format(last)} en ${n} ejecución`, + }; +} + +/** De cuántas métricas mejoran y cuántas empeoran a una sola palabra, para leer la tarjeta sin + * entrar en los números. */ +export function verdictOf(metrics: MetricView[]): Verdict { + const compared = metrics.filter((m) => m.change); + if (!compared.length) return { tone: "muted", label: "Sin comparación", detail: "un solo dato" }; + const better = compared.filter((m) => m.change?.tone === "ok").length; + const worse = compared.filter((m) => m.change?.tone === "bad").length; + const detail = `${better} mejoran · ${worse} empeoran · ${compared.length - better - worse} igual`; + if (better && !worse) return { tone: "ok", label: "Mejora", detail }; + if (worse && !better) return { tone: "bad", label: "Empeora", detail }; + if (better && worse) return { tone: "warn", label: "Mixto", detail }; + return { tone: "muted", label: "Sin cambios", detail }; +} + +/** La tarjeta de UN nivel de UNA tarea con UNA clase de datos. `runs` ya viene filtrado. */ +export function buildLevelCard(level: number, runs: Run[], kind: DataKind): LevelCardView { + const sorted = [...runs].sort(byDate); + const metrics = METRICS + .map((def) => metricOf(def, sorted)) + .filter((m): m is MetricView => m != null); + + const from = shortDate(sorted[0].started_at); + const to = shortDate(sorted[sorted.length - 1].started_at); + const seedRanges = new Set(sorted.map((r) => `${r.seed_min}-${r.seed_max}`)); + + return { + key: `${level}`, + title: `Nivel ${level}`, + meta: `${sorted.length} ${sorted.length === 1 ? "ejecución" : "ejecuciones"} · ` + + (from === to ? from : `${from} – ${to}`), + kind, + mixedSeeds: seedRanges.size > 1, + verdict: verdictOf(metrics), + metrics, + }; +} diff --git a/frontend/components/organisms/LevelCard/LevelCard.module.css b/frontend/components/organisms/LevelCard/LevelCard.module.css new file mode 100644 index 0000000..0712409 --- /dev/null +++ b/frontend/components/organisms/LevelCard/LevelCard.module.css @@ -0,0 +1,16 @@ +.card { min-width: 0; } + +.header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px 16px; + padding: 18px 24px; + border-bottom: 1px solid var(--border-soft); +} +.title { display: flex; align-items: baseline; gap: 12px; min-width: 0; flex-wrap: wrap; } +.name { font-weight: 700; } +.tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + +.body { padding: 0 24px; } diff --git a/frontend/components/organisms/LevelCard/LevelCard.tsx b/frontend/components/organisms/LevelCard/LevelCard.tsx new file mode 100644 index 0000000..25e0727 --- /dev/null +++ b/frontend/components/organisms/LevelCard/LevelCard.tsx @@ -0,0 +1,44 @@ +import { Badge, Card, Text } from "../../atoms"; +import { MetricTrend } from "../../molecules"; +import { KIND_TEXT, type LevelCardView } from "./LevelCard.helper"; +import styles from "./LevelCard.module.css"; + +/** Cómo va un nivel de una tarea: un veredicto arriba y, debajo, cada métrica con dónde está, + * cuánto ha cambiado y cómo ha ido. Las etiquetas avisan de lo que resta fiabilidad: datos que + * no son de verdad o semillas distintas dentro de la misma serie. */ +export function LevelCard({ view }: { view: LevelCardView }) { + const dashed = view.kind !== "measured"; + return ( + +
+
+ {view.title} + {view.meta} +
+
+ {view.kind !== "measured" && ( + {KIND_TEXT[view.kind]} + )} + {view.mixedSeeds && ( + + Semillas distintas + + )} + + + {view.verdict.label} + + +
+
+ +
+ {view.metrics.map((m) => ( + + ))} +
+
+ ); +} diff --git a/frontend/components/organisms/LevelCard/index.ts b/frontend/components/organisms/LevelCard/index.ts new file mode 100644 index 0000000..8a31872 --- /dev/null +++ b/frontend/components/organisms/LevelCard/index.ts @@ -0,0 +1,3 @@ +export { LevelCard } from "./LevelCard"; +export { buildLevelCard, kindOf, KIND_TEXT } from "./LevelCard.helper"; +export type { DataKind, LevelCardView, MetricView, Verdict } from "./LevelCard.helper"; diff --git a/frontend/components/organisms/TaskSection/TaskSection.module.css b/frontend/components/organisms/TaskSection/TaskSection.module.css new file mode 100644 index 0000000..6d0aed1 --- /dev/null +++ b/frontend/components/organisms/TaskSection/TaskSection.module.css @@ -0,0 +1,13 @@ +.section { display: flex; flex-direction: column; gap: 14px; } + +.header { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; } +.title { font-size: 20px; font-weight: 700; } + +/* Dos por fila en una pantalla grande, una en la estrecha: cada tarjeta necesita sitio para + sus tres columnas (valor, cambio, línea). */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 560px), 1fr)); + gap: var(--page-gap); + align-items: start; +} diff --git a/frontend/components/organisms/TaskSection/TaskSection.tsx b/frontend/components/organisms/TaskSection/TaskSection.tsx new file mode 100644 index 0000000..634ca24 --- /dev/null +++ b/frontend/components/organisms/TaskSection/TaskSection.tsx @@ -0,0 +1,27 @@ +import { Text } from "../../atoms"; +import { LevelCard, type LevelCardView } from "../LevelCard"; +import styles from "./TaskSection.module.css"; + +export type TaskSectionView = { + task: string; + title: string; + /** «2 niveles · 15 ejecuciones». */ + subtitle: string; + levels: LevelCardView[]; +}; + +/** Una tarea y, debajo, una tarjeta por nivel: se compara un nivel consigo mismo a lo largo del + * tiempo, nunca un nivel con otro (el criterio de éxito es distinto). */ +export function TaskSection({ view }: { view: TaskSectionView }) { + return ( +
+
+

{view.title}

+ {view.subtitle} +
+
+ {view.levels.map((l) => )} +
+
+ ); +} diff --git a/frontend/components/organisms/TaskSection/index.ts b/frontend/components/organisms/TaskSection/index.ts new file mode 100644 index 0000000..ae3d03e --- /dev/null +++ b/frontend/components/organisms/TaskSection/index.ts @@ -0,0 +1,2 @@ +export { TaskSection } from "./TaskSection"; +export type { TaskSectionView } from "./TaskSection"; diff --git a/frontend/components/organisms/index.ts b/frontend/components/organisms/index.ts index d1a0e60..0a096fd 100644 --- a/frontend/components/organisms/index.ts +++ b/frontend/components/organisms/index.ts @@ -17,3 +17,7 @@ export { TopBar } from "./TopBar"; export { EpisodeDashboard, buildEpisodeView } from "./EpisodeDashboard"; export type { EpisodeView } from "./EpisodeDashboard"; export { RunHeader, buildRunHeader } from "./RunHeader"; +export { LevelCard, buildLevelCard, kindOf, KIND_TEXT } from "./LevelCard"; +export type { DataKind, LevelCardView } from "./LevelCard"; +export { TaskSection } from "./TaskSection"; +export type { TaskSectionView } from "./TaskSection"; diff --git a/frontend/lib/ui.ts b/frontend/lib/ui.ts index 9e808be..f127760 100644 --- a/frontend/lib/ui.ts +++ b/frontend/lib/ui.ts @@ -96,6 +96,11 @@ export function changeTone( return (lowerIsBetter ? diff < 0 : diff > 0) ? "ok" : "bad"; } +/** Primera letra en mayúscula: «paletizado» → «Paletizado». */ +export function capitalize(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} + /** «19 sep». */ export function shortDate(iso: string | null | undefined): string { if (!iso) return "—"; From bf941bc7e7d49fc67060df0b1335b4d0d049e79c Mon Sep 17 00:00:00 2001 From: Ander Varela Date: Sat, 19 Sep 2026 16:30:11 +0200 Subject: [PATCH 03/14] =?UTF-8?q?fix(front):=20la=20serie=20de=20un=20nive?= =?UTF-8?q?l=20usa=20el=20rango=20de=20semillas=20m=C3=A1s=20frecuente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La curva se limitaba a las ejecuciones comparables con la más reciente. Con datos reales (pruebas de humo, cada una con semillas distintas) una sola ejecución suelta se quedaba con la serie y dejaba fuera a todas las demás: la tarjeta salía con una ejecución y «sin comparación». Ahora la referencia es el rango de semillas con MÁS ejecuciones (a igualdad, el de la más reciente), que es el protocolo real de un benchmark. Sigue usando comparability(), la misma regla que bloquea la comparativa: lo que no la cumple no entra en la curva, y la tarjeta dice cuántas se dejan fuera («+3 con otras semillas»). --- .../organisms/LevelCard/LevelCard.helper.ts | 37 +++++++++++++++---- .../organisms/LevelCard/LevelCard.tsx | 12 +++--- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/frontend/components/organisms/LevelCard/LevelCard.helper.ts b/frontend/components/organisms/LevelCard/LevelCard.helper.ts index 416d5c6..6f408c9 100644 --- a/frontend/components/organisms/LevelCard/LevelCard.helper.ts +++ b/frontend/components/organisms/LevelCard/LevelCard.helper.ts @@ -1,4 +1,4 @@ -import type { Run } from "@/lib/supabase"; +import { comparability, type Run } from "@/lib/supabase"; import { changeTone, mm, pct, seconds, shortDate, signedMm, signedNumber, splitQuantity, } from "@/lib/ui"; @@ -38,8 +38,11 @@ export type LevelCardView = { /** «15 ejecuciones · 19 sep – 21 sep». */ meta: string; kind: DataKind; - /** La serie mezcla rangos de semillas distintos: se compara suerte, no código. */ - mixedSeeds: boolean; + /** Con qué semillas se mide la serie (las de más ejecuciones), por ejemplo «semillas 20–44». */ + seeds: string; + /** Ejecuciones de este nivel que se dejan fuera de la curva por no ser comparables con las de la + * serie (otro rango de semillas): con otras semillas se compara suerte, no código. */ + excluded: number; verdict: Verdict; metrics: MetricView[]; }; @@ -128,16 +131,35 @@ export function verdictOf(metrics: MetricView[]): Verdict { return { tone: "muted", label: "Sin cambios", detail }; } -/** La tarjeta de UN nivel de UNA tarea con UNA clase de datos. `runs` ya viene filtrado. */ +/** El protocolo de la serie: el rango de semillas con MÁS ejecuciones (a igualdad, el de la más + * reciente). Así una prueba suelta con otras semillas no le quita la serie a todo un benchmark. + * Devuelve una ejecución de ese grupo, que hace de referencia para `comparability()`. */ +function referenceRun(sorted: Run[]): Run { + const groups = new Map(); + for (const r of sorted) { + const key = `${r.seed_min}-${r.seed_max}`; + groups.set(key, [...(groups.get(key) ?? []), r]); + } + const best = [...groups.values()].sort((a, b) => + b.length - a.length || Date.parse(b[b.length - 1].started_at) - Date.parse(a[a.length - 1].started_at))[0]; + return best[best.length - 1]; +} + +/** La tarjeta de UN nivel de UNA tarea con UNA clase de datos. `runs` ya viene filtrado. + * + * La curva usa solo las ejecuciones comparables entre sí (`comparability()`, la misma regla que + * bloquea la comparativa): un delta entre dos ejecuciones que no miden lo mismo es un gráfico + * bonito y falso. Las demás se dejan fuera y se cuentan, no se esconden. */ export function buildLevelCard(level: number, runs: Run[], kind: DataKind): LevelCardView { - const sorted = [...runs].sort(byDate); + const all = [...runs].sort(byDate); + const latest = referenceRun(all); + const sorted = all.filter((r) => comparability(latest, r).length === 0); const metrics = METRICS .map((def) => metricOf(def, sorted)) .filter((m): m is MetricView => m != null); const from = shortDate(sorted[0].started_at); const to = shortDate(sorted[sorted.length - 1].started_at); - const seedRanges = new Set(sorted.map((r) => `${r.seed_min}-${r.seed_max}`)); return { key: `${level}`, @@ -145,7 +167,8 @@ export function buildLevelCard(level: number, runs: Run[], kind: DataKind): Leve meta: `${sorted.length} ${sorted.length === 1 ? "ejecución" : "ejecuciones"} · ` + (from === to ? from : `${from} – ${to}`), kind, - mixedSeeds: seedRanges.size > 1, + seeds: latest.seed_min == null ? "" : `semillas ${latest.seed_min}–${latest.seed_max}`, + excluded: all.length - sorted.length, verdict: verdictOf(metrics), metrics, }; diff --git a/frontend/components/organisms/LevelCard/LevelCard.tsx b/frontend/components/organisms/LevelCard/LevelCard.tsx index 25e0727..e8d975c 100644 --- a/frontend/components/organisms/LevelCard/LevelCard.tsx +++ b/frontend/components/organisms/LevelCard/LevelCard.tsx @@ -5,7 +5,7 @@ import styles from "./LevelCard.module.css"; /** Cómo va un nivel de una tarea: un veredicto arriba y, debajo, cada métrica con dónde está, * cuánto ha cambiado y cómo ha ido. Las etiquetas avisan de lo que resta fiabilidad: datos que - * no son de verdad o semillas distintas dentro de la misma serie. */ + * no son de verdad, o ejecuciones que se dejan fuera por no ser comparables. */ export function LevelCard({ view }: { view: LevelCardView }) { const dashed = view.kind !== "measured"; return ( @@ -13,15 +13,17 @@ export function LevelCard({ view }: { view: LevelCardView }) {
{view.title} - {view.meta} + + {[view.meta, view.seeds].filter(Boolean).join(" · ")} +
{view.kind !== "measured" && ( {KIND_TEXT[view.kind]} )} - {view.mixedSeeds && ( - - Semillas distintas + {view.excluded > 0 && ( + + +{view.excluded} con otras semillas )} From e7a1a48ac7c876e15bbc52c577f5dac334a71e85 Mon Sep 17 00:00:00 2001 From: Ander Varela Date: Sat, 19 Sep 2026 16:30:13 +0200 Subject: [PATCH 04/14] =?UTF-8?q?feat(front):=20el=20resumen=20de=20evoluc?= =?UTF-8?q?i=C3=B3n=20es=20la=20pantalla=20principal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit / pasa a ser el Resumen y Live se mueve a /live. La barra tiene tres pestañas (Resumen, Live, Ejecuciones); routes.ts reúne las rutas y activeTab distingue las tres. El resumen enseña cómo ha evolucionado cada tarea, nivel por nivel, con las métricas de v_run_summary (tasa de éxito, tiempo de ciclo, margen de estabilidad y error de colocación). Para leerlo de un vistazo: - «De un vistazo»: un chip por tarea y nivel con su veredicto (Mejora, Mixto, Empeora, Sin comparación); pulsarlo lleva a su tarjeta. VerdictChip llama a scrollIntoView y no usa el #hash: la que scrollea es un contenedor de la pantalla, no el documento. - Cada tarjeta: valor actual, cambio desde la primera ejecución (la flecha dice hacia dónde se movió el valor y el color si eso es bueno o malo) y la línea de evolución. - Un selector de clase de datos: medidos, oracle y sembrados NO se mezclan nunca en una serie; por defecto lo medido y, si no hay, lo primero que haya, con su aviso. - Una leyenda de lectura, en una línea. No hace falta ningún endpoint nuevo. Reutiliza useRuns con la misma clave que Ejecuciones. La pantalla tiene altura fija y scrollea por dentro (la página no scrollea nunca); sus hijos no se encogen, porque las tarjetas llevan overflow: hidden y en una columna flex se aplastarían. Con tres pestañas la barra desbordaba 27 px a 390 px: se aprietan en móvil. AGENTS.md: rutas y estado al día. --- AGENTS.md | 11 ++- frontend/app/live/page.tsx | 5 ++ frontend/app/page.tsx | 4 +- .../ChangeLegend/ChangeLegend.module.css | 1 + .../molecules/ChangeLegend/ChangeLegend.tsx | 19 +++++ .../molecules/ChangeLegend/index.ts | 1 + .../molecules/NavTab/NavTab.module.css | 5 ++ .../VerdictChip/VerdictChip.module.css | 14 ++++ .../molecules/VerdictChip/VerdictChip.tsx | 28 +++++++ .../components/molecules/VerdictChip/index.ts | 1 + frontend/components/molecules/index.ts | 2 + .../OverviewStrip/OverviewStrip.helper.ts | 6 ++ .../OverviewStrip/OverviewStrip.module.css | 5 ++ .../organisms/OverviewStrip/OverviewStrip.tsx | 33 ++++++++ .../organisms/OverviewStrip/index.ts | 1 + .../TaskSection/TaskSection.module.css | 3 + .../organisms/TaskSection/TaskSection.tsx | 11 ++- .../components/organisms/TaskSection/index.ts | 2 +- .../organisms/TopBar/TopBar.module.css | 3 +- .../components/organisms/TopBar/TopBar.tsx | 16 ++-- frontend/components/organisms/index.ts | 3 +- .../screens/AppHeader/AppHeader.helper.ts | 13 ++- .../DashboardScreen/DashboardScreen.helper.ts | 59 ++++++++++++++ .../DashboardScreen.module.css | 36 +++++++++ .../DashboardScreen/DashboardScreen.tsx | 81 +++++++++++++++++++ .../screens/DashboardScreen/index.ts | 1 + .../DashboardScreen/useDashboardScreen.ts | 40 +++++++++ frontend/lib/routes.ts | 4 +- 28 files changed, 389 insertions(+), 19 deletions(-) create mode 100644 frontend/app/live/page.tsx create mode 100644 frontend/components/molecules/ChangeLegend/ChangeLegend.module.css create mode 100644 frontend/components/molecules/ChangeLegend/ChangeLegend.tsx create mode 100644 frontend/components/molecules/ChangeLegend/index.ts create mode 100644 frontend/components/molecules/VerdictChip/VerdictChip.module.css create mode 100644 frontend/components/molecules/VerdictChip/VerdictChip.tsx create mode 100644 frontend/components/molecules/VerdictChip/index.ts create mode 100644 frontend/components/organisms/OverviewStrip/OverviewStrip.helper.ts create mode 100644 frontend/components/organisms/OverviewStrip/OverviewStrip.module.css create mode 100644 frontend/components/organisms/OverviewStrip/OverviewStrip.tsx create mode 100644 frontend/components/organisms/OverviewStrip/index.ts create mode 100644 frontend/components/screens/DashboardScreen/DashboardScreen.helper.ts create mode 100644 frontend/components/screens/DashboardScreen/DashboardScreen.module.css create mode 100644 frontend/components/screens/DashboardScreen/DashboardScreen.tsx create mode 100644 frontend/components/screens/DashboardScreen/index.ts create mode 100644 frontend/components/screens/DashboardScreen/useDashboardScreen.ts diff --git a/AGENTS.md b/AGENTS.md index 106724e..3c72f7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ backend/ SQL + SDK. Supabase ES el backend; no hay servidor prop pyproject.toml frontend/ Next.js 16. Lee Supabase con la clave anon, bajo RLS. - app/ rutas: / (Live), /runs + app/ rutas: / (Resumen), /live, /runs, /runs/[id], /runs/[id]/[seed] api/clients/ una función por endpoint de PostgREST api/hooks/ los mismos, envueltos en TanStack Query components/ atoms / organisms / screens @@ -250,11 +250,16 @@ evolución en vez de borrón y cuenta nueva. `end()` además de `episode()`. **La simulación tiene que llamarlas** o Live enseñará el palé ya montado: `episode()` sube todo de golpe y entonces no hay fila `running` ni `UPDATE` a los que Realtime pueda reaccionar. -- **Front:** `/` (Live) y `/runs` (Ejecuciones), sobre TanStack Query, con Realtime y +- **Front:** `/` (**Resumen** de evolución, la pantalla principal), `/live`, `/runs` + (Ejecuciones) y el detalle de una ejecución, sobre TanStack Query, con Realtime y los tres estados no felices. El tamaño del palé sale de `config.pallet_size_m` vía `palletSize()`: **no** se dibuja siempre a 1200x800, porque el palé real puede ser una maqueta a escala y entonces todas las cotas saldrían mal por el mismo factor. -- **Pendiente:** las rutas `/runs/[id]` y `/runs/[id]/[seed]`. +- **Resumen:** una tarjeta por tarea y nivel, con las métricas de `v_run_summary` (éxito, + ciclo, estabilidad, error) y su cambio desde la primera ejecución. Aplica la misma regla que + la comparativa (`comparability()`): la curva usa solo ejecuciones comparables entre sí, que + en un nivel son las del rango de semillas más usado; las demás se dejan fuera y se cuentan. + Medidos, oracle y sembrados **no se mezclan** nunca en una serie. Aviso de Next 16: `params` es una Promise (`const { id } = await params`) y existen los tipos globales `PageProps<'/runs/[id]'>`. Hay documentación offline en diff --git a/frontend/app/live/page.tsx b/frontend/app/live/page.tsx new file mode 100644 index 0000000..fe95a47 --- /dev/null +++ b/frontend/app/live/page.tsx @@ -0,0 +1,5 @@ +import { LiveScreen } from "@/components/screens/LiveScreen"; + +export default function Page() { + return ; +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index fe95a47..8c1821a 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,5 +1,5 @@ -import { LiveScreen } from "@/components/screens/LiveScreen"; +import { DashboardScreen } from "@/components/screens/DashboardScreen"; export default function Page() { - return ; + return ; } diff --git a/frontend/components/molecules/ChangeLegend/ChangeLegend.module.css b/frontend/components/molecules/ChangeLegend/ChangeLegend.module.css new file mode 100644 index 0000000..805ba83 --- /dev/null +++ b/frontend/components/molecules/ChangeLegend/ChangeLegend.module.css @@ -0,0 +1 @@ +.legend { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; } diff --git a/frontend/components/molecules/ChangeLegend/ChangeLegend.tsx b/frontend/components/molecules/ChangeLegend/ChangeLegend.tsx new file mode 100644 index 0000000..006f972 --- /dev/null +++ b/frontend/components/molecules/ChangeLegend/ChangeLegend.tsx @@ -0,0 +1,19 @@ +import { Text } from "../../atoms"; +import { ChangePill } from "../ChangePill"; +import styles from "./ChangeLegend.module.css"; + +/** Cómo se lee el resumen, en una línea: la flecha dice hacia dónde se movió el valor y el color, + * si eso es bueno o malo. Sin esto un «▼ −0.4 s» en verde parece un error. */ +export function ChangeLegend() { + return ( +
+ Cambio desde la primera ejecución: + mejora + empeora + igual + + La flecha es hacia dónde se movió el valor; el color, si eso es bueno o malo. + +
+ ); +} diff --git a/frontend/components/molecules/ChangeLegend/index.ts b/frontend/components/molecules/ChangeLegend/index.ts new file mode 100644 index 0000000..566a51f --- /dev/null +++ b/frontend/components/molecules/ChangeLegend/index.ts @@ -0,0 +1 @@ +export { ChangeLegend } from "./ChangeLegend"; diff --git a/frontend/components/molecules/NavTab/NavTab.module.css b/frontend/components/molecules/NavTab/NavTab.module.css index bb4d4a6..fd4a303 100644 --- a/frontend/components/molecules/NavTab/NavTab.module.css +++ b/frontend/components/molecules/NavTab/NavTab.module.css @@ -9,3 +9,8 @@ .tab:hover { color: var(--text); background: var(--row-hover); } /* La pestaña activa, en tinta: negro sobre blanco en el tema claro, y al revés. */ .active, .active:hover { color: var(--on-select); background: var(--select); } + +/* Con tres pestañas, en un móvil no caben con su relleno de escritorio. */ +@media (max-width: 720px) { + .tab { padding: 8px 11px; font-size: 13.5px; } +} diff --git a/frontend/components/molecules/VerdictChip/VerdictChip.module.css b/frontend/components/molecules/VerdictChip/VerdictChip.module.css new file mode 100644 index 0000000..13c4721 --- /dev/null +++ b/frontend/components/molecules/VerdictChip/VerdictChip.module.css @@ -0,0 +1,14 @@ +.chip { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 16px; + text-decoration: none; + color: inherit; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + white-space: nowrap; +} +.chip:hover { background: var(--row-hover); border-color: var(--border-hard); } +.label { font-weight: 600; } diff --git a/frontend/components/molecules/VerdictChip/VerdictChip.tsx b/frontend/components/molecules/VerdictChip/VerdictChip.tsx new file mode 100644 index 0000000..ebe94dc --- /dev/null +++ b/frontend/components/molecules/VerdictChip/VerdictChip.tsx @@ -0,0 +1,28 @@ +import { Dot, Text } from "../../atoms"; +import styles from "./VerdictChip.module.css"; + +type Props = { + href: string; + /** «Nivel 2». */ + label: string; + /** «Mejora», «Mixto», «Empeora»… */ + verdict: string; + tone: "ok" | "warn" | "bad" | "muted"; + /** Lo que hay detrás del veredicto: «3 mejoran · 1 empeoran · 0 igual». */ + detail: string; + /** Qué hacer al pulsarlo: ir a su tarjeta. Con `href` sigue siendo un enlace de verdad (abrir + * en otra pestaña, copiar), pero el clic normal lo gestiona quien lo usa. */ + onSelect?: () => void; +}; + +/** Un nivel y cómo va, en una línea. Es un enlace: lleva a su tarjeta con el detalle. */ +export function VerdictChip({ href, label, verdict, tone, detail, onSelect }: Props) { + return ( + { e.preventDefault(); onSelect(); })}> + + {label} + {verdict} + + ); +} diff --git a/frontend/components/molecules/VerdictChip/index.ts b/frontend/components/molecules/VerdictChip/index.ts new file mode 100644 index 0000000..d1de05c --- /dev/null +++ b/frontend/components/molecules/VerdictChip/index.ts @@ -0,0 +1 @@ +export { VerdictChip } from "./VerdictChip"; diff --git a/frontend/components/molecules/index.ts b/frontend/components/molecules/index.ts index a1fef70..f667673 100644 --- a/frontend/components/molecules/index.ts +++ b/frontend/components/molecules/index.ts @@ -25,3 +25,5 @@ export { SegmentedControl } from "./SegmentedControl"; export { ChangePill } from "./ChangePill"; export { MetricTrend } from "./MetricTrend"; export type { MetricChange } from "./MetricTrend"; +export { ChangeLegend } from "./ChangeLegend"; +export { VerdictChip } from "./VerdictChip"; diff --git a/frontend/components/organisms/OverviewStrip/OverviewStrip.helper.ts b/frontend/components/organisms/OverviewStrip/OverviewStrip.helper.ts new file mode 100644 index 0000000..9de1a3a --- /dev/null +++ b/frontend/components/organisms/OverviewStrip/OverviewStrip.helper.ts @@ -0,0 +1,6 @@ +/** Lleva la pantalla a una tarjeta. La navegación por `#hash` no vale aquí: la que scrollea es un + * contenedor de la pantalla y no el documento, y `scrollIntoView` sí lo respeta (y respeta + * `scroll-margin-top` y el `scroll-behavior` del CSS, así que «reducir movimiento» salta). */ +export function scrollToAnchor(id: string): void { + document.getElementById(id)?.scrollIntoView({ block: "start" }); +} diff --git a/frontend/components/organisms/OverviewStrip/OverviewStrip.module.css b/frontend/components/organisms/OverviewStrip/OverviewStrip.module.css new file mode 100644 index 0000000..ac359b2 --- /dev/null +++ b/frontend/components/organisms/OverviewStrip/OverviewStrip.module.css @@ -0,0 +1,5 @@ +.strip { padding: 20px 24px; gap: 14px; } +.rows { display: flex; flex-direction: column; gap: 12px; } +.row { display: flex; align-items: center; gap: 12px 20px; flex-wrap: wrap; } +.task { font-weight: 700; min-width: 110px; } +.chips { display: flex; flex-wrap: wrap; gap: 8px; } diff --git a/frontend/components/organisms/OverviewStrip/OverviewStrip.tsx b/frontend/components/organisms/OverviewStrip/OverviewStrip.tsx new file mode 100644 index 0000000..72365c6 --- /dev/null +++ b/frontend/components/organisms/OverviewStrip/OverviewStrip.tsx @@ -0,0 +1,33 @@ +import { Card, Text } from "../../atoms"; +import { VerdictChip } from "../../molecules"; +import { scrollToAnchor } from "./OverviewStrip.helper"; +import type { TaskSectionView } from "../TaskSection"; +import { anchorOf } from "../TaskSection"; +import styles from "./OverviewStrip.module.css"; + +/** Todo el avance en una mirada: por cada tarea, un chip por nivel con su veredicto. Es lo primero + * que se lee; cada chip lleva a la tarjeta con las métricas y las curvas. */ +export function OverviewStrip({ sections }: { sections: TaskSectionView[] }) { + return ( + + De un vistazo +
+ {sections.map((t) => ( +
+ {t.title} +
+ {t.levels.map((l) => { + const id = anchorOf(t.task, l.key); + return ( + scrollToAnchor(id)} /> + ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/components/organisms/OverviewStrip/index.ts b/frontend/components/organisms/OverviewStrip/index.ts new file mode 100644 index 0000000..45c4e5f --- /dev/null +++ b/frontend/components/organisms/OverviewStrip/index.ts @@ -0,0 +1 @@ +export { OverviewStrip } from "./OverviewStrip"; diff --git a/frontend/components/organisms/TaskSection/TaskSection.module.css b/frontend/components/organisms/TaskSection/TaskSection.module.css index 6d0aed1..b0a2aee 100644 --- a/frontend/components/organisms/TaskSection/TaskSection.module.css +++ b/frontend/components/organisms/TaskSection/TaskSection.module.css @@ -1,3 +1,6 @@ +/* Al llegar desde un chip, la tarjeta no queda pegada al borde de la pantalla. */ +.anchor { scroll-margin-top: var(--page-pad); min-width: 0; } + .section { display: flex; flex-direction: column; gap: 14px; } .header { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; } diff --git a/frontend/components/organisms/TaskSection/TaskSection.tsx b/frontend/components/organisms/TaskSection/TaskSection.tsx index 634ca24..6b02b35 100644 --- a/frontend/components/organisms/TaskSection/TaskSection.tsx +++ b/frontend/components/organisms/TaskSection/TaskSection.tsx @@ -2,6 +2,11 @@ import { Text } from "../../atoms"; import { LevelCard, type LevelCardView } from "../LevelCard"; import styles from "./TaskSection.module.css"; +/** El id que lleva a la tarjeta de un nivel de una tarea. */ +export function anchorOf(task: string, levelKey: string): string { + return `${task}-nivel-${levelKey}`; +} + export type TaskSectionView = { task: string; title: string; @@ -20,7 +25,11 @@ export function TaskSection({ view }: { view: TaskSectionView }) { {view.subtitle}
- {view.levels.map((l) => )} + {view.levels.map((l) => ( +
+ +
+ ))}
); diff --git a/frontend/components/organisms/TaskSection/index.ts b/frontend/components/organisms/TaskSection/index.ts index ae3d03e..32e7efe 100644 --- a/frontend/components/organisms/TaskSection/index.ts +++ b/frontend/components/organisms/TaskSection/index.ts @@ -1,2 +1,2 @@ -export { TaskSection } from "./TaskSection"; +export { TaskSection, anchorOf } from "./TaskSection"; export type { TaskSectionView } from "./TaskSection"; diff --git a/frontend/components/organisms/TopBar/TopBar.module.css b/frontend/components/organisms/TopBar/TopBar.module.css index b5b6907..a740796 100644 --- a/frontend/components/organisms/TopBar/TopBar.module.css +++ b/frontend/components/organisms/TopBar/TopBar.module.css @@ -13,5 +13,6 @@ .spacer { flex-grow: 1; } @media (max-width: 720px) { - .bar { gap: 12px; padding: 0 16px; } + .bar { gap: 8px; padding: 0 16px; } + .nav { gap: 0; } } diff --git a/frontend/components/organisms/TopBar/TopBar.tsx b/frontend/components/organisms/TopBar/TopBar.tsx index 4a59a5a..c318cd6 100644 --- a/frontend/components/organisms/TopBar/TopBar.tsx +++ b/frontend/components/organisms/TopBar/TopBar.tsx @@ -1,20 +1,24 @@ import type { ReactNode } from "react"; import Link from "next/link"; import { Logo } from "../../atoms"; +import { routes } from "@/lib/routes"; import { NavTab } from "../../molecules"; import styles from "./TopBar.module.css"; -/** Barra global: el logo, dos destinos (la demo y el análisis) y, a la derecha, lo que se le - * pase. Run y Episodio se alcanzan desde la tabla: son detalle, no secciones. */ -export function TopBar({ active, children }: { active: "live" | "runs"; children?: ReactNode }) { +/** Barra global: el logo, tres destinos (el resumen, la demo y el análisis) y, a la derecha, lo que + * se le pase. Run y Episodio se alcanzan desde la tabla: son detalle, no secciones. */ +export function TopBar({ active, children }: { + active: "dashboard" | "live" | "runs"; children?: ReactNode; +}) { return (
- + {children} diff --git a/frontend/components/organisms/index.ts b/frontend/components/organisms/index.ts index 0a096fd..08c3ce9 100644 --- a/frontend/components/organisms/index.ts +++ b/frontend/components/organisms/index.ts @@ -19,5 +19,6 @@ export type { EpisodeView } from "./EpisodeDashboard"; export { RunHeader, buildRunHeader } from "./RunHeader"; export { LevelCard, buildLevelCard, kindOf, KIND_TEXT } from "./LevelCard"; export type { DataKind, LevelCardView } from "./LevelCard"; -export { TaskSection } from "./TaskSection"; +export { TaskSection, anchorOf } from "./TaskSection"; export type { TaskSectionView } from "./TaskSection"; +export { OverviewStrip } from "./OverviewStrip"; diff --git a/frontend/components/screens/AppHeader/AppHeader.helper.ts b/frontend/components/screens/AppHeader/AppHeader.helper.ts index 3d5f8af..bbb4c59 100644 --- a/frontend/components/screens/AppHeader/AppHeader.helper.ts +++ b/frontend/components/screens/AppHeader/AppHeader.helper.ts @@ -1,4 +1,11 @@ -/** Qué pestaña está activa según la ruta: Ejecuciones y todo lo que cuelga de /runs. */ -export function activeTab(path: string | null): "live" | "runs" { - return path?.startsWith("/runs") ? "runs" : "live"; +import { routes } from "@/lib/routes"; + +export type Tab = "dashboard" | "live" | "runs"; + +/** Qué pestaña está activa según la ruta: Ejecuciones y todo lo que cuelga de /runs, Live en su + * ruta y, en cualquier otra (la principal), el resumen. */ +export function activeTab(path: string | null): Tab { + if (path?.startsWith(routes.runs)) return "runs"; + if (path?.startsWith(routes.live)) return "live"; + return "dashboard"; } diff --git a/frontend/components/screens/DashboardScreen/DashboardScreen.helper.ts b/frontend/components/screens/DashboardScreen/DashboardScreen.helper.ts new file mode 100644 index 0000000..e11bf68 --- /dev/null +++ b/frontend/components/screens/DashboardScreen/DashboardScreen.helper.ts @@ -0,0 +1,59 @@ +import type { Run } from "@/lib/supabase"; +import { TASK_TEXT, capitalize } from "@/lib/ui"; +import { buildLevelCard, kindOf, type DataKind } from "../../organisms/LevelCard"; +import type { TaskSectionView } from "../../organisms/TaskSection"; + +/** Se piden todas: el resumen agrupa por tarea y nivel en la vista, y una ejecución que se + * quedara fuera del límite falsearía la primera parte de su curva. */ +export const RUN_LIMIT = 200; + +export const KIND_ORDER: DataKind[] = ["measured", "oracle", "synthetic"]; + +/** Lo que hay que saber de cada clase de datos, dicho junto al selector. */ +export const KIND_NOTE: Record = { + measured: null, + oracle: "Con oracle la percepción se sustituye por poses reales: no son números de verdad.", + synthetic: "Datos sembrados: generados para poder ver la interfaz, no medidos.", +}; + +/** La tarea en la que se trabaja ahora primero; la línea base de inducción, después. */ +const TASK_ORDER = ["palletizing", "induction"]; + +export function kindCounts(runs: Run[]): Record { + const counts: Record = { measured: 0, oracle: 0, synthetic: 0 }; + for (const r of runs) counts[kindOf(r)] += 1; + return counts; +} + +/** Lo que se enseña al entrar: lo medido si lo hay y, si no, lo primero que haya. Nunca se + * elige por defecto algo que no es de verdad habiendo algo que sí lo es. */ +export function defaultKind(counts: Record): DataKind { + return KIND_ORDER.find((k) => counts[k] > 0) ?? "measured"; +} + +function plural(n: number, one: string, many: string): string { + return `${n} ${n === 1 ? one : many}`; +} + +/** Una sección por tarea y, dentro, una tarjeta por nivel, con las ejecuciones de UNA clase de + * datos. Agrupar y ordenar es presentación; los números de cada ejecución los calculó la vista. */ +export function buildDashboard(runs: Run[], kind: DataKind): TaskSectionView[] { + const ofKind = runs.filter((r) => kindOf(r) === kind); + const tasks = [...new Set(ofKind.map((r) => r.task))].sort((a, b) => { + const ia = TASK_ORDER.indexOf(a); + const ib = TASK_ORDER.indexOf(b); + return (ia < 0 ? TASK_ORDER.length : ia) - (ib < 0 ? TASK_ORDER.length : ib) || a.localeCompare(b); + }); + + return tasks.map((task) => { + const ofTask = ofKind.filter((r) => r.task === task); + const levels = [...new Set(ofTask.map((r) => r.level))].sort((a, b) => a - b); + return { + task, + title: capitalize(TASK_TEXT[task] ?? task), + subtitle: `${plural(levels.length, "nivel", "niveles")} · ${plural(ofTask.length, "ejecución", "ejecuciones")}`, + levels: levels.map((level) => + buildLevelCard(level, ofTask.filter((r) => r.level === level), kind)), + }; + }); +} diff --git a/frontend/components/screens/DashboardScreen/DashboardScreen.module.css b/frontend/components/screens/DashboardScreen/DashboardScreen.module.css new file mode 100644 index 0000000..de85871 --- /dev/null +++ b/frontend/components/screens/DashboardScreen/DashboardScreen.module.css @@ -0,0 +1,36 @@ +/* Altura fija, la de la ventana menos la barra global: la página no scrollea nunca. Lo que no + cabe (varias tareas con varios niveles) scrollea DENTRO de la pantalla. */ +.screen { + height: calc(100vh - var(--topbar-h)); + height: calc(100dvh - var(--topbar-h)); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 28px; + padding: var(--page-pad); +} + +/* Lo que no cabe se scrollea, no se encoge: las tarjetas llevan `overflow: hidden` y, en una + columna flex, un hijo así se aplastaría antes que empujar. */ +.screen > * { flex-shrink: 0; } + +.head { + display: flex; + align-items: flex-end; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px 24px; +} +.titles { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.title { font-size: 26px; font-weight: 700; } + +.guide { display: flex; flex-direction: column; gap: 10px; } + +/* Ir a una tarjeta desde un chip se desliza; con «reducir movimiento», salta. */ +@media (prefers-reduced-motion: no-preference) { + .screen { scroll-behavior: smooth; } +} + +@media (max-width: 860px) { + .screen { padding: 16px; gap: 20px; } +} diff --git a/frontend/components/screens/DashboardScreen/DashboardScreen.tsx b/frontend/components/screens/DashboardScreen/DashboardScreen.tsx new file mode 100644 index 0000000..255b853 --- /dev/null +++ b/frontend/components/screens/DashboardScreen/DashboardScreen.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Button, Text } from "../../atoms"; +import { + ChangeLegend, EmptyState, InfoMessage, Notice, SegmentedControl, +} from "../../molecules"; +import { KIND_TEXT, OverviewStrip, TaskSection } from "../../organisms"; +import { routes } from "@/lib/routes"; +import { KIND_NOTE } from "./DashboardScreen.helper"; +import styles from "./DashboardScreen.module.css"; +import { useDashboardScreen } from "./useDashboardScreen"; + +/** Resumen de cómo ha ido evolucionando cada tarea, nivel por nivel: es la pantalla principal. + * Cada tarjeta responde a «¿vamos mejor?» de un vistazo: un veredicto y, por métrica, dónde + * estamos, cuánto ha cambiado desde el principio y cómo ha ido. */ +export function DashboardScreen() { + const s = useDashboardScreen(); + + if (s.state === "unconfigured") { + return ( + + Rellena .env en la raíz del repo con SUPABASE_URL y{" "} + SUPABASE_ANON_KEY. + + ); + } + if (s.state === "loading") return Buscando las ejecuciones.; + if (s.state === "error") { + return ( + + {s.error} + + ); + } + if (s.state === "empty") { + return ( + + Lanza un benchmark con --telemetry, o siembra un histórico con{" "} + backend/seed/palletizing.py. + + ); + } + + const note = KIND_NOTE[s.kind]; + + return ( +
+
+
+

Evolución

+ + Cómo ha ido cambiando cada tarea, ejecución a ejecución, nivel por nivel. + +
+ ({ + value: k, label: KIND_TEXT[k], count, + }))} + /> +
+ + {s.sections.length > 0 && } + +
+ + {note && {note}} +
+ + {s.sections.length === 0 ? ( + + Elige otra clase arriba para ver las que sí hay. + + ) : ( + s.sections.map((t) => ) + )} +
+ ); +} diff --git a/frontend/components/screens/DashboardScreen/index.ts b/frontend/components/screens/DashboardScreen/index.ts new file mode 100644 index 0000000..7c484f5 --- /dev/null +++ b/frontend/components/screens/DashboardScreen/index.ts @@ -0,0 +1 @@ +export { DashboardScreen } from "./DashboardScreen"; diff --git a/frontend/components/screens/DashboardScreen/useDashboardScreen.ts b/frontend/components/screens/DashboardScreen/useDashboardScreen.ts new file mode 100644 index 0000000..2c3c230 --- /dev/null +++ b/frontend/components/screens/DashboardScreen/useDashboardScreen.ts @@ -0,0 +1,40 @@ +import { useState } from "react"; +import { useRuns } from "@/api/hooks"; +import { configured } from "@/lib/supabase"; +import type { DataKind } from "../../organisms/LevelCard"; +import { + KIND_ORDER, RUN_LIMIT, buildDashboard, defaultKind, kindCounts, +} from "./DashboardScreen.helper"; + +/** Une los datos del resumen y la clase de datos elegida en un solo modelo. Aquí no se dibuja + * nada. Comparte consulta con Ejecuciones (mismo límite, misma clave): no pide dos veces. */ +export function useDashboardScreen() { + const query = useRuns(RUN_LIMIT); + const runs = query.data ?? []; + + // `null` = no ha elegido: se enseña lo medido y, si no hay, lo primero que haya. + const [chosen, setChosen] = useState(null); + const counts = kindCounts(runs); + const kind = chosen ?? defaultKind(counts); + + const state = !configured ? "unconfigured" as const + : query.isPending ? "loading" as const + : query.isError ? "error" as const + : runs.length === 0 ? "empty" as const + : "ready" as const; + + return { + state, + error: query.error?.message, + retry: () => { void query.refetch(); }, + kind, + options: KIND_ORDER.map((k) => ({ k, count: counts[k] })), + sections: buildDashboard(runs, kind), + totalRuns: runs.length, + // El selector devuelve un texto: solo vale si es una de las clases que existen. + onKind: (value: string) => { + const next = KIND_ORDER.find((k) => k === value); + if (next) setChosen(next); + }, + }; +} diff --git a/frontend/lib/routes.ts b/frontend/lib/routes.ts index 0f8a0e7..b5b841d 100644 --- a/frontend/lib/routes.ts +++ b/frontend/lib/routes.ts @@ -1,6 +1,8 @@ /** Las rutas de la aplicación, en un solo sitio: quien enlaza no construye la URL a mano. */ export const routes = { - live: "/", + /** Resumen de evolución: la pantalla principal. */ + dashboard: "/", + live: "/live", runs: "/runs", /** Detalle de una ejecución: por defecto, su último episodio. */ run: (runId: string) => `/runs/${runId}`, From 296371c3fa3f7a83fb6b55070d0e3202f2d3de6c Mon Sep 17 00:00:00 2001 From: Ander Varela Date: Sat, 19 Sep 2026 16:40:55 +0200 Subject: [PATCH 05/14] style(front): el resumen ocupa todo el ancho, con lista lateral fija MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Con pocos niveles la tarjeta ocupaba la mitad de la pantalla y el resto quedaba en blanco, y «De un vistazo» era un bloque enorme con un solo chip. - Dos columnas que reparten todo el ancho: a la izquierda, una tarjeta por nivel a todo el ancho; a la derecha, fija al bajar, «De un vistazo» (una lista vertical de niveles con su veredicto y cuántas métricas mejoran o empeoran) y «Cómo leerlo». En pantalla mediana pasa arriba, con la lista y la guía lado a lado; en móvil, apiladas. - Las métricas de una tarjeta son mosaicos que se reparten el ancho de LA TARJETA (container query): 1 columna estrecha, 2×2 mediana y todas en fila si es muy ancha. Antes se repartían 3+1 y quedaba un mosaico huérfano. - Cada mosaico apila valor, cambio y línea, y el gráfico es más ancho (viewBox 400×64) para que no se vea letterboxed en un mosaico grande. - VerdictChip pasa a VerdictRow y OverviewStrip a OverviewPanel: ya no son ni chip ni franja. Comprobado en Chrome con datos reales y con un mock de varias tareas y niveles, en los dos temas, y con iframes de 1850, 1440, 1100, 1000, 800, 390 y 360 px: la página no scrollea ni desborda en ninguno y la lista lateral se queda fija al bajar. --- .../components/atoms/Trend/Trend.helper.ts | 8 ++-- .../components/atoms/Trend/Trend.module.css | 2 +- .../ChangeLegend/ChangeLegend.module.css | 5 ++- .../molecules/ChangeLegend/ChangeLegend.tsx | 19 +++++---- .../MetricTrend/MetricTrend.module.css | 28 ++++++------- .../molecules/MetricTrend/MetricTrend.tsx | 15 +++---- .../VerdictChip/VerdictChip.module.css | 14 ------- .../components/molecules/VerdictChip/index.ts | 1 - .../VerdictRow/VerdictRow.module.css | 15 +++++++ .../VerdictRow.tsx} | 12 +++--- .../components/molecules/VerdictRow/index.ts | 1 + frontend/components/molecules/index.ts | 2 +- .../organisms/LevelCard/LevelCard.module.css | 21 ++++++++-- .../OverviewPanel.helper.ts} | 0 .../OverviewPanel/OverviewPanel.module.css | 6 +++ .../organisms/OverviewPanel/OverviewPanel.tsx | 34 ++++++++++++++++ .../organisms/OverviewPanel/index.ts | 1 + .../OverviewStrip/OverviewStrip.module.css | 5 --- .../organisms/OverviewStrip/OverviewStrip.tsx | 33 ---------------- .../organisms/OverviewStrip/index.ts | 1 - .../TaskSection/TaskSection.module.css | 15 +++---- frontend/components/organisms/index.ts | 2 +- .../DashboardScreen.module.css | 39 +++++++++++++++++-- .../DashboardScreen/DashboardScreen.tsx | 37 ++++++++++-------- 24 files changed, 185 insertions(+), 131 deletions(-) delete mode 100644 frontend/components/molecules/VerdictChip/VerdictChip.module.css delete mode 100644 frontend/components/molecules/VerdictChip/index.ts create mode 100644 frontend/components/molecules/VerdictRow/VerdictRow.module.css rename frontend/components/molecules/{VerdictChip/VerdictChip.tsx => VerdictRow/VerdictRow.tsx} (60%) create mode 100644 frontend/components/molecules/VerdictRow/index.ts rename frontend/components/organisms/{OverviewStrip/OverviewStrip.helper.ts => OverviewPanel/OverviewPanel.helper.ts} (100%) create mode 100644 frontend/components/organisms/OverviewPanel/OverviewPanel.module.css create mode 100644 frontend/components/organisms/OverviewPanel/OverviewPanel.tsx create mode 100644 frontend/components/organisms/OverviewPanel/index.ts delete mode 100644 frontend/components/organisms/OverviewStrip/OverviewStrip.module.css delete mode 100644 frontend/components/organisms/OverviewStrip/OverviewStrip.tsx delete mode 100644 frontend/components/organisms/OverviewStrip/index.ts diff --git a/frontend/components/atoms/Trend/Trend.helper.ts b/frontend/components/atoms/Trend/Trend.helper.ts index b19e9aa..4adeb37 100644 --- a/frontend/components/atoms/Trend/Trend.helper.ts +++ b/frontend/components/atoms/Trend/Trend.helper.ts @@ -10,10 +10,10 @@ export type TrendGeometry = { baselineY: number | null; }; -const W = 200; -const H = 52; -const PAD_X = 8; -const PAD_Y = 8; +const W = 400; +const H = 64; +const PAD_X = 10; +const PAD_Y = 10; /** Con más puntos que esto los círculos se pisan: solo se marca el último. */ const MAX_DOTS = 40; diff --git a/frontend/components/atoms/Trend/Trend.module.css b/frontend/components/atoms/Trend/Trend.module.css index c7c797b..f08cb0a 100644 --- a/frontend/components/atoms/Trend/Trend.module.css +++ b/frontend/components/atoms/Trend/Trend.module.css @@ -1,4 +1,4 @@ -.trend { width: 100%; height: 52px; display: block; overflow: visible; } +.trend { width: 100%; height: 64px; display: block; overflow: visible; } .ok { --tone: var(--ok); } .bad { --tone: var(--bad); } diff --git a/frontend/components/molecules/ChangeLegend/ChangeLegend.module.css b/frontend/components/molecules/ChangeLegend/ChangeLegend.module.css index 805ba83..943e05f 100644 --- a/frontend/components/molecules/ChangeLegend/ChangeLegend.module.css +++ b/frontend/components/molecules/ChangeLegend/ChangeLegend.module.css @@ -1 +1,4 @@ -.legend { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; } +.legend { display: flex; flex-direction: column; gap: 12px; } +.title { font-weight: 700; } +.pills { display: flex; flex-wrap: wrap; gap: 8px; } +.text { line-height: 1.55; } diff --git a/frontend/components/molecules/ChangeLegend/ChangeLegend.tsx b/frontend/components/molecules/ChangeLegend/ChangeLegend.tsx index 006f972..28909e2 100644 --- a/frontend/components/molecules/ChangeLegend/ChangeLegend.tsx +++ b/frontend/components/molecules/ChangeLegend/ChangeLegend.tsx @@ -2,17 +2,20 @@ import { Text } from "../../atoms"; import { ChangePill } from "../ChangePill"; import styles from "./ChangeLegend.module.css"; -/** Cómo se lee el resumen, en una línea: la flecha dice hacia dónde se movió el valor y el color, - * si eso es bueno o malo. Sin esto un «▼ −0.4 s» en verde parece un error. */ +/** Cómo se lee el resumen: la flecha dice hacia dónde se movió el valor y el color, si eso es + * bueno o malo. Sin esto un «▼ −0.4 s» en verde parece un error. */ export function ChangeLegend() { return (
- Cambio desde la primera ejecución: - mejora - empeora - igual - - La flecha es hacia dónde se movió el valor; el color, si eso es bueno o malo. + Cómo leerlo +
+ mejora + empeora + igual +
+ + Cada cambio se cuenta desde la primera ejecución. La flecha es hacia dónde se movió el + valor; el color, si eso es bueno o malo.
); diff --git a/frontend/components/molecules/MetricTrend/MetricTrend.module.css b/frontend/components/molecules/MetricTrend/MetricTrend.module.css index 17df6f1..acbeb7c 100644 --- a/frontend/components/molecules/MetricTrend/MetricTrend.module.css +++ b/frontend/components/molecules/MetricTrend/MetricTrend.module.css @@ -1,19 +1,15 @@ -.row { - display: grid; - grid-template-columns: minmax(130px, 1fr) minmax(130px, 1fr) minmax(150px, 1.3fr); - align-items: center; - gap: 16px 24px; - padding: 18px 0; - border-bottom: 1px solid var(--border-soft); +.tile { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + padding: 20px 22px 18px; + background: var(--row-alt); + border: 1px solid var(--border-soft); + border-radius: var(--radius-2); } -.row:last-child { border-bottom: 0; } -.now { display: flex; flex-direction: column; gap: 8px; min-width: 0; } -.change { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-width: 0; } -.chart { min-width: 0; } +.change { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; } -/* Estrecho: la línea baja a su propia fila en vez de aplastarse. */ -@media (max-width: 720px) { - .row { grid-template-columns: 1fr 1fr; } - .chart { grid-column: 1 / -1; } -} +/* La línea se queda abajo aunque los mosaicos de una fila tengan alturas distintas. */ +.chart { margin-top: auto; padding-top: 10px; } diff --git a/frontend/components/molecules/MetricTrend/MetricTrend.tsx b/frontend/components/molecules/MetricTrend/MetricTrend.tsx index 2de5a0a..06c7a68 100644 --- a/frontend/components/molecules/MetricTrend/MetricTrend.tsx +++ b/frontend/components/molecules/MetricTrend/MetricTrend.tsx @@ -23,23 +23,24 @@ type Props = { chartLabel: string; }; -/** Una métrica de una serie: dónde está ahora, cuánto ha cambiado desde el principio y cómo ha - * ido en el camino. Las tres cosas juntas responden a «¿vamos mejor?» de un vistazo. */ +/** Una métrica de una serie, como un mosaico: dónde está ahora, cuánto ha cambiado desde el + * principio y cómo ha ido en el camino. Las tres cosas juntas responden a «¿vamos mejor?» de un + * vistazo. */ export function MetricTrend({ label, value, unit, change, note, points, tone, dashed, chartLabel, }: Props) { return ( -
-
- {label} - -
+
+ {label} + +
{change ? {change.text} : sin comparación} {note}
+
diff --git a/frontend/components/molecules/VerdictChip/VerdictChip.module.css b/frontend/components/molecules/VerdictChip/VerdictChip.module.css deleted file mode 100644 index 13c4721..0000000 --- a/frontend/components/molecules/VerdictChip/VerdictChip.module.css +++ /dev/null @@ -1,14 +0,0 @@ -.chip { - display: inline-flex; - align-items: center; - gap: 10px; - padding: 8px 16px; - text-decoration: none; - color: inherit; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-pill); - white-space: nowrap; -} -.chip:hover { background: var(--row-hover); border-color: var(--border-hard); } -.label { font-weight: 600; } diff --git a/frontend/components/molecules/VerdictChip/index.ts b/frontend/components/molecules/VerdictChip/index.ts deleted file mode 100644 index d1de05c..0000000 --- a/frontend/components/molecules/VerdictChip/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { VerdictChip } from "./VerdictChip"; diff --git a/frontend/components/molecules/VerdictRow/VerdictRow.module.css b/frontend/components/molecules/VerdictRow/VerdictRow.module.css new file mode 100644 index 0000000..27a933d --- /dev/null +++ b/frontend/components/molecules/VerdictRow/VerdictRow.module.css @@ -0,0 +1,15 @@ +.row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 2px 12px; + padding: 10px 12px; + text-decoration: none; + color: inherit; + border-radius: var(--radius-1); +} +.row:hover { background: var(--row-hover); } +.label { font-weight: 600; } +.verdict { font-weight: 600; } +/* El detalle va debajo, alineado con el nombre y no con el punto. */ +.detail { grid-column: 2 / -1; } diff --git a/frontend/components/molecules/VerdictChip/VerdictChip.tsx b/frontend/components/molecules/VerdictRow/VerdictRow.tsx similarity index 60% rename from frontend/components/molecules/VerdictChip/VerdictChip.tsx rename to frontend/components/molecules/VerdictRow/VerdictRow.tsx index ebe94dc..575c501 100644 --- a/frontend/components/molecules/VerdictChip/VerdictChip.tsx +++ b/frontend/components/molecules/VerdictRow/VerdictRow.tsx @@ -1,5 +1,5 @@ import { Dot, Text } from "../../atoms"; -import styles from "./VerdictChip.module.css"; +import styles from "./VerdictRow.module.css"; type Props = { href: string; @@ -15,14 +15,16 @@ type Props = { onSelect?: () => void; }; -/** Un nivel y cómo va, en una línea. Es un enlace: lleva a su tarjeta con el detalle. */ -export function VerdictChip({ href, label, verdict, tone, detail, onSelect }: Props) { +/** Un nivel y cómo va, en una fila de la lista. Es un enlace: lleva a su tarjeta. */ +export function VerdictRow({ href, label, verdict, tone, detail, onSelect }: Props) { return ( - { e.preventDefault(); onSelect(); })}> {label} - {verdict} + {verdict} + {detail} ); } diff --git a/frontend/components/molecules/VerdictRow/index.ts b/frontend/components/molecules/VerdictRow/index.ts new file mode 100644 index 0000000..9765823 --- /dev/null +++ b/frontend/components/molecules/VerdictRow/index.ts @@ -0,0 +1 @@ +export { VerdictRow } from "./VerdictRow"; diff --git a/frontend/components/molecules/index.ts b/frontend/components/molecules/index.ts index f667673..e0eca9b 100644 --- a/frontend/components/molecules/index.ts +++ b/frontend/components/molecules/index.ts @@ -26,4 +26,4 @@ export { ChangePill } from "./ChangePill"; export { MetricTrend } from "./MetricTrend"; export type { MetricChange } from "./MetricTrend"; export { ChangeLegend } from "./ChangeLegend"; -export { VerdictChip } from "./VerdictChip"; +export { VerdictRow } from "./VerdictRow"; diff --git a/frontend/components/organisms/LevelCard/LevelCard.module.css b/frontend/components/organisms/LevelCard/LevelCard.module.css index 0712409..1197191 100644 --- a/frontend/components/organisms/LevelCard/LevelCard.module.css +++ b/frontend/components/organisms/LevelCard/LevelCard.module.css @@ -1,4 +1,4 @@ -.card { min-width: 0; } +.card { min-width: 0; container-type: inline-size; } .header { display: flex; @@ -6,11 +6,24 @@ justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; - padding: 18px 24px; - border-bottom: 1px solid var(--border-soft); + padding: 20px 24px 0; } .title { display: flex; align-items: baseline; gap: 12px; min-width: 0; flex-wrap: wrap; } .name { font-weight: 700; } .tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } -.body { padding: 0 24px; } +/* Las métricas se reparten el ancho de LA TARJETA, no el de la ventana: una columna en una tarjeta + estrecha, 2×2 en una media y todas en fila en una muy ancha. Así nunca queda un mosaico huérfano + ni media tarjeta vacía. */ +.body { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + padding: 20px 24px 24px; +} +@container (min-width: 640px) { + .body { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@container (min-width: 1180px) { + .body { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } +} diff --git a/frontend/components/organisms/OverviewStrip/OverviewStrip.helper.ts b/frontend/components/organisms/OverviewPanel/OverviewPanel.helper.ts similarity index 100% rename from frontend/components/organisms/OverviewStrip/OverviewStrip.helper.ts rename to frontend/components/organisms/OverviewPanel/OverviewPanel.helper.ts diff --git a/frontend/components/organisms/OverviewPanel/OverviewPanel.module.css b/frontend/components/organisms/OverviewPanel/OverviewPanel.module.css new file mode 100644 index 0000000..958f868 --- /dev/null +++ b/frontend/components/organisms/OverviewPanel/OverviewPanel.module.css @@ -0,0 +1,6 @@ +.panel { padding: 22px 16px 16px; gap: 14px; } +.title { font-weight: 700; padding: 0 12px; } +.tasks { display: flex; flex-direction: column; gap: 16px; } +.task { display: flex; flex-direction: column; gap: 4px; } +.task > :first-child { padding: 0 12px; } +.rows { display: flex; flex-direction: column; } diff --git a/frontend/components/organisms/OverviewPanel/OverviewPanel.tsx b/frontend/components/organisms/OverviewPanel/OverviewPanel.tsx new file mode 100644 index 0000000..519c112 --- /dev/null +++ b/frontend/components/organisms/OverviewPanel/OverviewPanel.tsx @@ -0,0 +1,34 @@ +import { Card, Text } from "../../atoms"; +import { VerdictRow } from "../../molecules"; +import type { TaskSectionView } from "../TaskSection"; +import { anchorOf } from "../TaskSection"; +import { scrollToAnchor } from "./OverviewPanel.helper"; +import styles from "./OverviewPanel.module.css"; + +/** Todo el avance en una mirada: por cada tarea, una fila por nivel con su veredicto. Va en la + * columna lateral y se queda a la vista mientras se baja; cada fila lleva a la tarjeta con las + * métricas y las curvas. */ +export function OverviewPanel({ sections }: { sections: TaskSectionView[] }) { + return ( + + De un vistazo +
+ {sections.map((t) => ( +
+ {t.title} +
+ {t.levels.map((l) => { + const id = anchorOf(t.task, l.key); + return ( + scrollToAnchor(id)} /> + ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/components/organisms/OverviewPanel/index.ts b/frontend/components/organisms/OverviewPanel/index.ts new file mode 100644 index 0000000..a81a15a --- /dev/null +++ b/frontend/components/organisms/OverviewPanel/index.ts @@ -0,0 +1 @@ +export { OverviewPanel } from "./OverviewPanel"; diff --git a/frontend/components/organisms/OverviewStrip/OverviewStrip.module.css b/frontend/components/organisms/OverviewStrip/OverviewStrip.module.css deleted file mode 100644 index ac359b2..0000000 --- a/frontend/components/organisms/OverviewStrip/OverviewStrip.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.strip { padding: 20px 24px; gap: 14px; } -.rows { display: flex; flex-direction: column; gap: 12px; } -.row { display: flex; align-items: center; gap: 12px 20px; flex-wrap: wrap; } -.task { font-weight: 700; min-width: 110px; } -.chips { display: flex; flex-wrap: wrap; gap: 8px; } diff --git a/frontend/components/organisms/OverviewStrip/OverviewStrip.tsx b/frontend/components/organisms/OverviewStrip/OverviewStrip.tsx deleted file mode 100644 index 72365c6..0000000 --- a/frontend/components/organisms/OverviewStrip/OverviewStrip.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Card, Text } from "../../atoms"; -import { VerdictChip } from "../../molecules"; -import { scrollToAnchor } from "./OverviewStrip.helper"; -import type { TaskSectionView } from "../TaskSection"; -import { anchorOf } from "../TaskSection"; -import styles from "./OverviewStrip.module.css"; - -/** Todo el avance en una mirada: por cada tarea, un chip por nivel con su veredicto. Es lo primero - * que se lee; cada chip lleva a la tarjeta con las métricas y las curvas. */ -export function OverviewStrip({ sections }: { sections: TaskSectionView[] }) { - return ( - - De un vistazo -
- {sections.map((t) => ( -
- {t.title} -
- {t.levels.map((l) => { - const id = anchorOf(t.task, l.key); - return ( - scrollToAnchor(id)} /> - ); - })} -
-
- ))} -
-
- ); -} diff --git a/frontend/components/organisms/OverviewStrip/index.ts b/frontend/components/organisms/OverviewStrip/index.ts deleted file mode 100644 index 45c4e5f..0000000 --- a/frontend/components/organisms/OverviewStrip/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OverviewStrip } from "./OverviewStrip"; diff --git a/frontend/components/organisms/TaskSection/TaskSection.module.css b/frontend/components/organisms/TaskSection/TaskSection.module.css index b0a2aee..a57237a 100644 --- a/frontend/components/organisms/TaskSection/TaskSection.module.css +++ b/frontend/components/organisms/TaskSection/TaskSection.module.css @@ -1,16 +1,11 @@ -/* Al llegar desde un chip, la tarjeta no queda pegada al borde de la pantalla. */ +/* Al llegar desde la lista lateral, la tarjeta no queda pegada al borde de la pantalla. */ .anchor { scroll-margin-top: var(--page-pad); min-width: 0; } -.section { display: flex; flex-direction: column; gap: 14px; } +.section { display: flex; flex-direction: column; gap: 16px; } .header { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; } .title { font-size: 20px; font-weight: 700; } -/* Dos por fila en una pantalla grande, una en la estrecha: cada tarjeta necesita sitio para - sus tres columnas (valor, cambio, línea). */ -.grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(100%, 560px), 1fr)); - gap: var(--page-gap); - align-items: start; -} +/* Un nivel por fila, a todo el ancho de la columna: así se lee de arriba abajo, nivel a nivel, y + no queda media pantalla vacía cuando solo hay uno. */ +.grid { display: flex; flex-direction: column; gap: var(--page-gap); } diff --git a/frontend/components/organisms/index.ts b/frontend/components/organisms/index.ts index 08c3ce9..1ac866f 100644 --- a/frontend/components/organisms/index.ts +++ b/frontend/components/organisms/index.ts @@ -21,4 +21,4 @@ export { LevelCard, buildLevelCard, kindOf, KIND_TEXT } from "./LevelCard"; export type { DataKind, LevelCardView } from "./LevelCard"; export { TaskSection, anchorOf } from "./TaskSection"; export type { TaskSectionView } from "./TaskSection"; -export { OverviewStrip } from "./OverviewStrip"; +export { OverviewPanel } from "./OverviewPanel"; diff --git a/frontend/components/screens/DashboardScreen/DashboardScreen.module.css b/frontend/components/screens/DashboardScreen/DashboardScreen.module.css index de85871..bf23fcc 100644 --- a/frontend/components/screens/DashboardScreen/DashboardScreen.module.css +++ b/frontend/components/screens/DashboardScreen/DashboardScreen.module.css @@ -22,15 +22,48 @@ gap: 16px 24px; } .titles { display: flex; flex-direction: column; gap: 6px; min-width: 0; } -.title { font-size: 26px; font-weight: 700; } +.title { font-size: 28px; font-weight: 700; } -.guide { display: flex; flex-direction: column; gap: 10px; } +/* Dos columnas que reparten TODO el ancho: el contenido crece con la pantalla y la lateral se + queda de un tamaño fijo, siempre a la vista. */ +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 28px; + align-items: start; +} +.content { display: flex; flex-direction: column; gap: 36px; min-width: 0; } + +/* Pegada arriba de la pantalla al bajar: el veredicto de cada nivel nunca se pierde de vista. */ +.rail { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + gap: var(--page-gap); +} +.guide { padding: 22px 28px; gap: 12px; } +.note { line-height: 1.5; } -/* Ir a una tarjeta desde un chip se desliza; con «reducir movimiento», salta. */ +/* Ir a una tarjeta desde la lista se desliza; con «reducir movimiento», salta. */ @media (prefers-reduced-motion: no-preference) { .screen { scroll-behavior: smooth; } } +/* Pantalla mediana o estrecha: una sola columna y la lista de veredictos va PRIMERO, para que lo + más importante se vea sin bajar. */ +@media (max-width: 1100px) { + .layout { grid-template-columns: minmax(0, 1fr); } + /* La lista y la guía se reparten la fila, en vez de una debajo de la otra a todo el ancho. */ + .rail { + position: static; + order: -1; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); + align-items: start; + } +} + @media (max-width: 860px) { .screen { padding: 16px; gap: 20px; } } diff --git a/frontend/components/screens/DashboardScreen/DashboardScreen.tsx b/frontend/components/screens/DashboardScreen/DashboardScreen.tsx index 255b853..4e1332d 100644 --- a/frontend/components/screens/DashboardScreen/DashboardScreen.tsx +++ b/frontend/components/screens/DashboardScreen/DashboardScreen.tsx @@ -1,18 +1,18 @@ "use client"; -import { Button, Text } from "../../atoms"; +import { Button, Card, Text } from "../../atoms"; import { ChangeLegend, EmptyState, InfoMessage, Notice, SegmentedControl, } from "../../molecules"; -import { KIND_TEXT, OverviewStrip, TaskSection } from "../../organisms"; +import { KIND_TEXT, OverviewPanel, TaskSection } from "../../organisms"; import { routes } from "@/lib/routes"; import { KIND_NOTE } from "./DashboardScreen.helper"; import styles from "./DashboardScreen.module.css"; import { useDashboardScreen } from "./useDashboardScreen"; /** Resumen de cómo ha ido evolucionando cada tarea, nivel por nivel: es la pantalla principal. - * Cada tarjeta responde a «¿vamos mejor?» de un vistazo: un veredicto y, por métrica, dónde - * estamos, cuánto ha cambiado desde el principio y cómo ha ido. */ + * A la izquierda, una tarjeta por nivel con sus métricas; a la derecha, fija, la lista «De un + * vistazo» con el veredicto de cada nivel y cómo leerlo. Ocupa todo el ancho de la pantalla. */ export function DashboardScreen() { const s = useDashboardScreen(); @@ -62,20 +62,25 @@ export function DashboardScreen() { />
- {s.sections.length > 0 && } +
+
+ {s.sections.length === 0 ? ( + + Elige otra clase arriba para ver las que sí hay. + + ) : ( + s.sections.map((t) => ) + )} +
-
- - {note && {note}} +
- - {s.sections.length === 0 ? ( - - Elige otra clase arriba para ver las que sí hay. - - ) : ( - s.sections.map((t) => ) - )} ); } From 53d1d8b1178d065eaae843b339e5b5c92894f347 Mon Sep 17 00:00:00 2001 From: Ander Varela Date: Sat, 19 Sep 2026 16:51:48 +0200 Subject: [PATCH 06/14] =?UTF-8?q?style(front):=20m=C3=A1s=20aire=20entre?= =?UTF-8?q?=20las=20tarjetas=20del=20resumen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las tarjetas se veían apretadas. Se abren los huecos del resumen: 32 px entre las tarjetas de nivel (antes 20), 52 entre tareas (36), 28 entre la lista «De un vistazo» y la guía de lectura (20), 40 entre la columna principal y la lateral (28) y 36 entre bloques de la pantalla (28). Dentro de una tarjeta de nivel, 20 px entre mosaicos y 28 de relleno lateral (16 y 24). En pantalla estrecha los huecos se reducen (32 entre columnas, 36 entre tareas). Comprobado en el servidor de desarrollo con un mock de varias tareas y niveles y con iframes de 1850, 1440, 1000, 800, 390 y 360 px: la página no scrollea ni desborda en ninguno. --- .../organisms/LevelCard/LevelCard.module.css | 6 +++--- .../organisms/TaskSection/TaskSection.module.css | 4 ++-- .../DashboardScreen/DashboardScreen.module.css | 13 +++++++------ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/frontend/components/organisms/LevelCard/LevelCard.module.css b/frontend/components/organisms/LevelCard/LevelCard.module.css index 1197191..4001d29 100644 --- a/frontend/components/organisms/LevelCard/LevelCard.module.css +++ b/frontend/components/organisms/LevelCard/LevelCard.module.css @@ -6,7 +6,7 @@ justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; - padding: 20px 24px 0; + padding: 24px 28px 0; } .title { display: flex; align-items: baseline; gap: 12px; min-width: 0; flex-wrap: wrap; } .name { font-weight: 700; } @@ -18,8 +18,8 @@ .body { display: grid; grid-template-columns: minmax(0, 1fr); - gap: 16px; - padding: 20px 24px 24px; + gap: 20px; + padding: 24px 28px 28px; } @container (min-width: 640px) { .body { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/frontend/components/organisms/TaskSection/TaskSection.module.css b/frontend/components/organisms/TaskSection/TaskSection.module.css index a57237a..572f110 100644 --- a/frontend/components/organisms/TaskSection/TaskSection.module.css +++ b/frontend/components/organisms/TaskSection/TaskSection.module.css @@ -1,11 +1,11 @@ /* Al llegar desde la lista lateral, la tarjeta no queda pegada al borde de la pantalla. */ .anchor { scroll-margin-top: var(--page-pad); min-width: 0; } -.section { display: flex; flex-direction: column; gap: 16px; } +.section { display: flex; flex-direction: column; gap: 20px; } .header { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; } .title { font-size: 20px; font-weight: 700; } /* Un nivel por fila, a todo el ancho de la columna: así se lee de arriba abajo, nivel a nivel, y no queda media pantalla vacía cuando solo hay uno. */ -.grid { display: flex; flex-direction: column; gap: var(--page-gap); } +.grid { display: flex; flex-direction: column; gap: 32px; } diff --git a/frontend/components/screens/DashboardScreen/DashboardScreen.module.css b/frontend/components/screens/DashboardScreen/DashboardScreen.module.css index bf23fcc..8f30440 100644 --- a/frontend/components/screens/DashboardScreen/DashboardScreen.module.css +++ b/frontend/components/screens/DashboardScreen/DashboardScreen.module.css @@ -6,7 +6,7 @@ overflow-y: auto; display: flex; flex-direction: column; - gap: 28px; + gap: 36px; padding: var(--page-pad); } @@ -29,10 +29,10 @@ .layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; - gap: 28px; + gap: 40px; align-items: start; } -.content { display: flex; flex-direction: column; gap: 36px; min-width: 0; } +.content { display: flex; flex-direction: column; gap: 52px; min-width: 0; } /* Pegada arriba de la pantalla al bajar: el veredicto de cada nivel nunca se pierde de vista. */ .rail { @@ -40,7 +40,7 @@ top: 0; display: flex; flex-direction: column; - gap: var(--page-gap); + gap: 28px; } .guide { padding: 22px 28px; gap: 12px; } .note { line-height: 1.5; } @@ -53,7 +53,7 @@ /* Pantalla mediana o estrecha: una sola columna y la lista de veredictos va PRIMERO, para que lo más importante se vea sin bajar. */ @media (max-width: 1100px) { - .layout { grid-template-columns: minmax(0, 1fr); } + .layout { grid-template-columns: minmax(0, 1fr); gap: 32px; } /* La lista y la guía se reparten la fila, en vez de una debajo de la otra a todo el ancho. */ .rail { position: static; @@ -65,5 +65,6 @@ } @media (max-width: 860px) { - .screen { padding: 16px; gap: 20px; } + .screen { padding: 16px; gap: 24px; } + .content { gap: 36px; } } From 665a1edfbe25c09b20ca5281299996c1ae3095b8 Mon Sep 17 00:00:00 2001 From: Ander Varela Date: Sat, 19 Sep 2026 17:05:03 +0200 Subject: [PATCH 07/14] =?UTF-8?q?fix(front):=20Live=20distingue=20la=20con?= =?UTF-8?q?exi=C3=B3n=20perdida=20del=20episodio=20que=20no=20env=C3=ADa?= =?UTF-8?q?=20datos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes 8 s sin eventos se leían como «Conexión perdida», aunque el canal de Realtime estuviera sano y solo callara quien produce (un paso lento, o un episodio que se quedó en running porque el proceso se cayó). Ahora: - conexión perdida: falla la consulta o el canal avisa de CHANNEL_ERROR, TIMED_OUT o CLOSED. - sin datos: canal sano y nada recibido en 30 s; aviso propio, sin «Reintentar». La marca de última señal pertenece a su episodio: al pasar al siguiente de una ejecución ya no arrastra el silencio del anterior. --- frontend/api/hooks/useEpisodeRealtime.ts | 24 +++++++++---- .../screens/LiveScreen/LiveScreen.helper.ts | 36 +++++++++++++------ .../screens/LiveScreen/LiveScreen.tsx | 9 +++-- .../screens/LiveScreen/useLiveScreen.ts | 23 ++++++------ 4 files changed, 62 insertions(+), 30 deletions(-) diff --git a/frontend/api/hooks/useEpisodeRealtime.ts b/frontend/api/hooks/useEpisodeRealtime.ts index c3971b0..550dae1 100644 --- a/frontend/api/hooks/useEpisodeRealtime.ts +++ b/frontend/api/hooks/useEpisodeRealtime.ts @@ -19,17 +19,21 @@ function append(prev: T[] | undefined, row: T): T[] { * - UPDATE en `episodes`: cambió el estado o la duración; se invalida todo lo que cuelga * de `episodes` (el episodio "último", el listado del run y su detalle). * - * Devuelve cuándo llegó lo último y si el canal está suscrito; con eso la pantalla - * decide si avisar de "conexión perdida". + * Devuelve cuándo llegó lo último, si el canal está suscrito y si ha avisado de un fallo; + * con eso la pantalla distingue "conexión perdida" (el canal) de "sin datos" (el productor). */ export function useEpisodeRealtime(episodeId: string | undefined) { const queryClient = useQueryClient(); - const [lastReceivedAt, setLastReceivedAt] = useState(null); + // La marca lleva el episodio al que pertenece: al pasar a otro, la del anterior no vale y + // no puede hacer creer que el nuevo lleva callado desde entonces. + const [signal, setSignal] = useState<{ id: string; at: number } | null>(null); const [subscribed, setSubscribed] = useState(false); + const [channelError, setChannelError] = useState(false); useEffect(() => { if (!configured || !episodeId) return; - const touch = () => setLastReceivedAt(Date.now()); + let disposed = false; + const touch = () => setSignal({ id: episodeId, at: Date.now() }); const channel = supabase .channel(`live-${episodeId}`) @@ -59,16 +63,24 @@ export function useEpisodeRealtime(episodeId: string | undefined) { touch(); }) .subscribe((status) => { + // Al retirar el canal también llega un CLOSED: no es un fallo, y no debe pisar al siguiente. + if (disposed) return; setSubscribed(status === "SUBSCRIBED"); - // Estar suscrito ya es una señal de vida: es la referencia del latido. + // El canal avisa él mismo de que se ha caído (y se reengancha solo): eso, y no el + // silencio del productor, es una conexión perdida. + setChannelError(status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED"); + // Estar suscrito ya es una señal de vida: es la referencia del silencio. if (status === "SUBSCRIBED") touch(); }); return () => { + disposed = true; setSubscribed(false); + setChannelError(false); void supabase.removeChannel(channel); }; }, [episodeId, queryClient]); - return { lastReceivedAt, subscribed }; + const lastReceivedAt = signal && signal.id === episodeId ? signal.at : null; + return { lastReceivedAt, subscribed, channelError }; } diff --git a/frontend/components/screens/LiveScreen/LiveScreen.helper.ts b/frontend/components/screens/LiveScreen/LiveScreen.helper.ts index 66a0613..9bccde7 100644 --- a/frontend/components/screens/LiveScreen/LiveScreen.helper.ts +++ b/frontend/components/screens/LiveScreen/LiveScreen.helper.ts @@ -1,9 +1,10 @@ import { clockTime } from "@/lib/ui"; -/** Sin nada recibido durante este tiempo, con un episodio en curso, se da la conexión - * por perdida. Generoso a propósito: un aviso en falso durante la demo es peor que - * enterarse dos segundos más tarde. */ -export const HEARTBEAT_MS = 8000; +/** Con un episodio en curso y el canal sano, sin recibir nada durante este tiempo se avisa de + * que el episodio no envía datos. No es una conexión perdida: la conexión va bien, es quien + * produce el que calla (un paso lento, o un proceso que se cayó sin cerrar el episodio). Por + * eso es holgado: un paso de planificación o de asentado puede tardar decenas de segundos. */ +export const SILENCE_MS = 30_000; /** Cada cuánto se pregunta si hay un episodio en curso. Es lo que hace aparecer uno que acaba * de arrancar (Realtime solo vigila el episodio que ya se está viendo) y lo que retira el @@ -15,13 +16,26 @@ export const LIVE_POLL_MS = 5000; * siguiente de una misma ejecución, para que Live no parpadee entre uno y otro. */ export const HOLD_MS = 10_000; -export function isStale(input: { - running: boolean; fetchFailed: boolean; lastSignalAt: number; now: number; -}): boolean { - const { running, fetchFailed, lastSignalAt, now } = input; - return fetchFailed || (running && now - lastSignalAt > HEARTBEAT_MS); +/** Por qué lo que se ve puede no estar al día: + * - `lost`: falla la conexión (la consulta o el canal de Realtime). Se congela lo último recibido. + * - `silent`: la conexión va bien pero el episodio en curso no manda nada. + * - `null`: al día. */ +export type Staleness = "lost" | "silent" | null; + +export function staleness(input: { + running: boolean; fetchFailed: boolean; channelError: boolean; + lastSignalAt: number | null; now: number; +}): Staleness { + const { running, fetchFailed, channelError, lastSignalAt, now } = input; + if (fetchFailed || channelError) return "lost"; + if (running && lastSignalAt !== null && now - lastSignalAt > SILENCE_MS) return "silent"; + return null; } -export function staleDetail(lastSignalAt: number): string { - return `lo que se ve es el último dato recibido · ${clockTime(new Date(lastSignalAt).toISOString())}`; +export function staleDetail(kind: Staleness, lastSignalAt: number | null): string | undefined { + if (lastSignalAt === null) return undefined; + const at = clockTime(new Date(lastSignalAt).toISOString()); + return kind === "lost" + ? `lo que se ve es el último dato recibido · ${at}` + : `último dato recibido · ${at}`; } diff --git a/frontend/components/screens/LiveScreen/LiveScreen.tsx b/frontend/components/screens/LiveScreen/LiveScreen.tsx index 47cf76e..f30c2f5 100644 --- a/frontend/components/screens/LiveScreen/LiveScreen.tsx +++ b/frontend/components/screens/LiveScreen/LiveScreen.tsx @@ -43,8 +43,13 @@ export function LiveScreen() { + s.silent ? ( + + ) : ( + + ) ) : s.finished ? ( diff --git a/frontend/components/screens/LiveScreen/useLiveScreen.ts b/frontend/components/screens/LiveScreen/useLiveScreen.ts index a055372..f9379e1 100644 --- a/frontend/components/screens/LiveScreen/useLiveScreen.ts +++ b/frontend/components/screens/LiveScreen/useLiveScreen.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useEpisodeDetail, useEpisodeRealtime, useEpisodes, useRunningEpisode, useRunningEpisodeRealtime, useSnapshots, @@ -7,7 +7,7 @@ import { configured } from "@/lib/supabase"; import { useHold } from "@/hooks/useHold"; import { useNow } from "@/hooks/useNow"; import { buildEpisodeView } from "../../organisms/EpisodeDashboard"; -import { HOLD_MS, LIVE_POLL_MS, isStale, staleDetail } from "./LiveScreen.helper"; +import { HOLD_MS, LIVE_POLL_MS, staleDetail, staleness } from "./LiveScreen.helper"; /** Une los datos de Live en un solo modelo para la pantalla. Aquí no se dibuja nada. * @@ -38,13 +38,12 @@ export function useLiveScreen() { }, [holding, refetchEpisodes]); const detail = useEpisodeDetail(episode?.id); - const { lastReceivedAt } = useEpisodeRealtime(episode?.id); + const { lastReceivedAt, channelError } = useEpisodeRealtime(episode?.id); const snapshots = useSnapshots(episode?.id); const now = useNow(episode ? 1000 : null); - // Hasta que llegue algo por Realtime, la referencia es la de cuando se montó la pantalla. - const [mountedAt] = useState(() => Date.now()); - const lastSignalAt = lastReceivedAt ?? mountedAt; + // Sin referencia (el canal aún no se ha suscrito a este episodio) no se puede acusar de silencio. + const lastSignalAt = lastReceivedAt; const retry = () => { void live.refetch(); }; @@ -57,19 +56,21 @@ export function useLiveScreen() { } if (!episode) return { state: "idle" as const, retry }; - // Con un episodio en pantalla, un fallo de red no lo vacía: se congela y se avisa. El latido - // solo vigila lo que corre: uno terminado no manda datos, y eso no es una conexión perdida. - const stale = isStale({ + // Con un episodio en pantalla, un fallo de red no lo vacía: se congela y se avisa. El silencio + // solo se vigila en lo que corre: uno terminado no manda datos, y eso no es un fallo. + const kind = staleness({ running: episode.status === "running", - fetchFailed: live.isError || detail.isError, lastSignalAt, now, + fetchFailed: live.isError || detail.isError, channelError, lastSignalAt, now, }); + const stale = kind !== null; return { state: "ready" as const, retry, stale, + silent: kind === "silent", finished: holding, - staleDetail: staleDetail(lastSignalAt), + staleDetail: staleDetail(kind, lastSignalAt), view: buildEpisodeView({ episode, placements: detail.placements.data ?? [], From e18e18f6d265549dd803518c76a51abb73de7bfa Mon Sep 17 00:00:00 2001 From: Ander Varela Date: Sat, 19 Sep 2026 17:18:23 +0200 Subject: [PATCH 08/14] =?UTF-8?q?fix(front):=20los=20rellenos=20del=20dibu?= =?UTF-8?q?jo=20del=20pal=C3=A9=20son=20opacos=20y=20la=20leyenda=20coinci?= =?UTF-8?q?de=20con=20ellos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Con transparencia, el último paquete se veía más oscuro que la muestra de la leyenda en cuanto había otro debajo (capa 2 sobre capa 1). Ahora se mezclan con --surface: un paquete tapa al de debajo y su color no depende de lo que haya detrás. --- frontend/styles/colors.css | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/styles/colors.css b/frontend/styles/colors.css index 1f1a1af..f72522f 100644 --- a/frontend/styles/colors.css +++ b/frontend/styles/colors.css @@ -137,11 +137,13 @@ :root { /* Relleno de lo seleccionado. */ --select-row: color-mix(in srgb, var(--select) 7%, transparent); /* fila de tabla */ - --select-fill: color-mix(in srgb, var(--select) 14%, transparent); /* último paquete, muestra de leyenda */ - /* Rellenos del dibujo del palé. */ - --neutral-fill: color-mix(in srgb, var(--text-2) 10%, transparent); /* paquete colocado */ - --bad-fill: color-mix(in srgb, var(--bad) 16%, transparent); /* paquete fuera de tolerancia */ + /* Rellenos del dibujo del palé. Son OPACOS (mezclados con --surface, no con transparencia): + un paquete tapa al que tiene debajo, como en la realidad, y su color no depende de lo que + haya detrás. Así la muestra de la leyenda es exactamente el color del dibujo. */ + --select-fill: color-mix(in srgb, var(--select) 14%, var(--surface)); /* último paquete, muestra de leyenda */ + --neutral-fill: color-mix(in srgb, var(--text-2) 10%, var(--surface)); /* paquete colocado */ + --bad-fill: color-mix(in srgb, var(--bad) 16%, var(--surface)); /* paquete fuera de tolerancia */ /* Aviso de conexión perdida. */ --warn-banner: color-mix(in srgb, var(--warn) 10%, transparent); From 18547f5a762372857f42f4e4f444aa1cc19e4bc6 Mon Sep 17 00:00:00 2001 From: Ander Varela Date: Sat, 19 Sep 2026 17:23:13 +0200 Subject: [PATCH 09/14] feat(front): selector Esquema/Captura y capturas enmarcadas en el panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - «Ver captura» / «Ver esquema» (un enlace subrayado) pasa a un control segmentado Esquema | Captura en la cabecera, siempre con las dos opciones a la vista. SegmentedControl gana la variante compact. La leyenda solo se enseña con el esquema. - La captura va en un marco que ocupa todo el panel, con las esquinas y el borde del resto de la interfaz, sin recortar la foto, y con una etiqueta «captura del simulador». - La cabecera de un panel pasa a línea siguiente si leyenda y control no caben (390 px). --- .../PanelHeader/PanelHeader.module.css | 5 ++-- .../SegmentedControl.module.css | 3 +++ .../SegmentedControl/SegmentedControl.tsx | 6 +++-- .../SnapshotView/SnapshotView.module.css | 27 +++++++++++++++++-- .../molecules/SnapshotView/SnapshotView.tsx | 24 ++++++++++------- .../EpisodeDashboard/EpisodeDashboard.tsx | 22 ++++++++------- 6 files changed, 63 insertions(+), 24 deletions(-) diff --git a/frontend/components/molecules/PanelHeader/PanelHeader.module.css b/frontend/components/molecules/PanelHeader/PanelHeader.module.css index 7bced49..bef13af 100644 --- a/frontend/components/molecules/PanelHeader/PanelHeader.module.css +++ b/frontend/components/molecules/PanelHeader/PanelHeader.module.css @@ -1,12 +1,13 @@ .header { min-height: 56px; flex-shrink: 0; - padding: 0 24px; + padding: 8px 24px; display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border-soft); } .title { font-weight: 600; } -.aside { display: flex; align-items: center; gap: 16px; min-width: 0; } +.aside { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px 16px; min-width: 0; } diff --git a/frontend/components/molecules/SegmentedControl/SegmentedControl.module.css b/frontend/components/molecules/SegmentedControl/SegmentedControl.module.css index 6cf2cf9..092aec7 100644 --- a/frontend/components/molecules/SegmentedControl/SegmentedControl.module.css +++ b/frontend/components/molecules/SegmentedControl/SegmentedControl.module.css @@ -29,3 +29,6 @@ .count { color: var(--text-4); } .empty .face { opacity: 0.55; } + +.compact { padding: 2px; } +.compact .face { padding: 4px 12px; font-size: 12.5px; } diff --git a/frontend/components/molecules/SegmentedControl/SegmentedControl.tsx b/frontend/components/molecules/SegmentedControl/SegmentedControl.tsx index 76b5bb9..efeecc9 100644 --- a/frontend/components/molecules/SegmentedControl/SegmentedControl.tsx +++ b/frontend/components/molecules/SegmentedControl/SegmentedControl.tsx @@ -11,13 +11,15 @@ type Props = { options: Option[]; value: string; onChange: (value: string) => void; + /** Más bajo, para cabeceras de panel. */ + compact?: boolean; }; /** Elegir una opción entre pocas, siempre a la vista. Radios nativos: teclado y lector de * pantalla gratis. `count` dice cuántas ejecuciones hay detrás de cada una. */ -export function SegmentedControl({ name, label, options, value, onChange }: Props) { +export function SegmentedControl({ name, label, options, value, onChange, compact }: Props) { return ( -
+
{options.map((o) => (
From d8ad4d1b67edb1672843ae39a238192bfb646544 Mon Sep 17 00:00:00 2001 From: Ander Varela Date: Sat, 19 Sep 2026 17:44:00 +0200 Subject: [PATCH 13/14] refactor(front): quita los iconos de ayuda de la cabecera de la tarjeta de nivel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Se mantienen los de las métricas, el selector de clases de datos y «De un vistazo». --- .../organisms/LevelCard/LevelCard.helper.ts | 12 ------ .../organisms/LevelCard/LevelCard.module.css | 4 +- .../organisms/LevelCard/LevelCard.tsx | 38 +++++-------------- 3 files changed, 11 insertions(+), 43 deletions(-) diff --git a/frontend/components/organisms/LevelCard/LevelCard.helper.ts b/frontend/components/organisms/LevelCard/LevelCard.helper.ts index f4e69b6..edb9081 100644 --- a/frontend/components/organisms/LevelCard/LevelCard.helper.ts +++ b/frontend/components/organisms/LevelCard/LevelCard.helper.ts @@ -23,18 +23,6 @@ export const KIND_HELP: Record = { "medidas en una misma curva.", }; -export const EXCLUDED_HELP = - "Estas ejecuciones usaron otro rango de semillas y no entran en la curva: con otras semillas se " + - "compara suerte, no código."; - -export const SEEDS_HELP = - "La semilla fija cómo se reparten los paquetes en un episodio. Solo se comparan ejecuciones que " + - "usaron las mismas semillas; la curva usa el rango de semillas con más ejecuciones."; - -export const VERDICT_HELP = - "Resume la tarjeta en una palabra según cuántas métricas van a mejor y cuántas a peor desde la " + - "primera ejecución: Mejora, Empeora, Mixto (unas y otras) o Sin cambios."; - export function kindOf(run: Run): DataKind { return run.synthetic ? "synthetic" : run.oracle ? "oracle" : "measured"; } diff --git a/frontend/components/organisms/LevelCard/LevelCard.module.css b/frontend/components/organisms/LevelCard/LevelCard.module.css index 7994094..4001d29 100644 --- a/frontend/components/organisms/LevelCard/LevelCard.module.css +++ b/frontend/components/organisms/LevelCard/LevelCard.module.css @@ -10,9 +10,7 @@ } .title { display: flex; align-items: baseline; gap: 12px; min-width: 0; flex-wrap: wrap; } .name { font-weight: 700; } -.tags { display: flex; align-items: center; gap: 8px 12px; flex-wrap: wrap; } -.tag { display: inline-flex; align-items: center; gap: 2px; } -.meta { display: inline-flex; align-items: center; gap: 2px; } +.tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } /* Las métricas se reparten el ancho de LA TARJETA, no el de la ventana: una columna en una tarjeta estrecha, 2×2 en una media y todas en fila en una muy ancha. Así nunca queda un mosaico huérfano diff --git a/frontend/components/organisms/LevelCard/LevelCard.tsx b/frontend/components/organisms/LevelCard/LevelCard.tsx index a12301a..60aa5c1 100644 --- a/frontend/components/organisms/LevelCard/LevelCard.tsx +++ b/frontend/components/organisms/LevelCard/LevelCard.tsx @@ -1,8 +1,6 @@ import { Badge, Card, Text } from "../../atoms"; -import { InfoTip, MetricTrend } from "../../molecules"; -import { - EXCLUDED_HELP, KIND_HELP, KIND_TEXT, SEEDS_HELP, VERDICT_HELP, type LevelCardView, -} from "./LevelCard.helper"; +import { MetricTrend } from "../../molecules"; +import { KIND_TEXT, type LevelCardView } from "./LevelCard.helper"; import styles from "./LevelCard.module.css"; /** Cómo va un nivel de una tarea: un veredicto arriba y, debajo, cada métrica con dónde está, @@ -15,36 +13,20 @@ export function LevelCard({ view }: { view: LevelCardView }) {
{view.title} - - - {[view.meta, view.seeds].filter(Boolean).join(" · ")} - - {view.seeds && {SEEDS_HELP}} - + + {[view.meta, view.seeds].filter(Boolean).join(" · ")} +
{view.kind !== "measured" && ( - - {KIND_TEXT[view.kind]} - - {KIND_HELP[view.kind]} - - + {KIND_TEXT[view.kind]} )} {view.excluded > 0 && ( - - +{view.excluded} con otras semillas - {EXCLUDED_HELP} - + +{view.excluded} con otras semillas )} - - - {view.verdict.label} - - - {`${view.verdict.detail}. ${VERDICT_HELP}`} - - + + {view.verdict.label} +
From 39f38b22af8448fa7f6bc1d2412a1f80e7bff384 Mon Sep 17 00:00:00 2001 From: Ander Varela Date: Sat, 19 Sep 2026 17:46:10 +0200 Subject: [PATCH 14/14] fix(front): la comparativa no falla al comparar dos ejecuciones del mismo commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las barras de causas de fallo usaban el nombre de la ejecución (el commit) como key de React, y dos ejecuciones del mismo commit lo comparten: «Encountered two children with the same key». Ahora la key es el id de la ejecución. --- .../components/organisms/ComparePanel/ComparePanel.helper.ts | 3 +++ frontend/components/organisms/CompareResult/CompareResult.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts b/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts index 7242ee3..b753ec6 100644 --- a/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts +++ b/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts @@ -17,6 +17,8 @@ export type DeltaView = { label: string; value: string; unit: string; detail: string; tone: "ok" | "bad" | "muted"; }; export type BarView = { + /** La ejecución: dos ejecuciones pueden ser del mismo commit y tener el mismo nombre. */ + key: string; label: string; segments: Array<{ key: string; n: number; color: string; title: string }>; }; @@ -66,6 +68,7 @@ function delta(input: { function barOf(run: Run, rows: FailureBreakdownRow[] | undefined): BarView { return { + key: run.id, label: runName(run), segments: (rows ?? []).map((r) => ({ key: r.failure, n: r.n, color: failureColor(r.failure), title: failureText(r.failure), diff --git a/frontend/components/organisms/CompareResult/CompareResult.tsx b/frontend/components/organisms/CompareResult/CompareResult.tsx index d08a836..9a17398 100644 --- a/frontend/components/organisms/CompareResult/CompareResult.tsx +++ b/frontend/components/organisms/CompareResult/CompareResult.tsx @@ -20,7 +20,7 @@ export function CompareResult({ deltas, failuresTitle, bars, legend }: Props) {
{failuresTitle}
- {bars.map((b) => )} + {bars.map(({ key, ...b }) => )}
{legend.map((l) => )}