diff --git a/AGENTS.md b/AGENTS.md index 4edab9a..050df8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,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, /runs/[id], /runs/[id]/[seed] + 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 @@ -264,12 +264,18 @@ 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. - **Detalle de ejecución:** `/runs/[id]` y `/runs/[id]/[seed]` ya están, sobre el mismo `EpisodeDashboard` que Live: cambia de dónde salen los datos, no cómo se ven. +- **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/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/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/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..17c9d7a 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" | "info"; /* Trazos de 16×16. Iconos de línea, sin relleno: ninguno lleva color propio, heredan `currentColor` del que los use. */ @@ -18,6 +18,9 @@ const PATHS: Record = { chevronLeft: , chevronRight: , arrowLeft: , + arrowUp: , + minus: , + info: (<>), }; /** 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..4adeb37 --- /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 = 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; + +/** 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..f08cb0a --- /dev/null +++ b/frontend/components/atoms/Trend/Trend.module.css @@ -0,0 +1,19 @@ +.trend { width: 100%; height: 64px; 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/ChangeLegend/ChangeLegend.module.css b/frontend/components/molecules/ChangeLegend/ChangeLegend.module.css new file mode 100644 index 0000000..943e05f --- /dev/null +++ b/frontend/components/molecules/ChangeLegend/ChangeLegend.module.css @@ -0,0 +1,4 @@ +.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 new file mode 100644 index 0000000..95fda57 --- /dev/null +++ b/frontend/components/molecules/ChangeLegend/ChangeLegend.tsx @@ -0,0 +1,22 @@ +import { Text } from "../../atoms"; +import { ChangePill } from "../ChangePill"; +import styles from "./ChangeLegend.module.css"; + +/** Cómo se lee el resumen: la flecha y el color dicen si fue a mejor o a peor, y el signo, hacia + * dónde se movió el número. Sin esto un «↑ −0.4 s» en verde parece un error. */ +export function ChangeLegend() { + return ( +
+ Cómo leerlo +
+ mejora + empeora + igual +
+ + Cada cambio se cuenta desde la primera ejecución. La flecha y el color dicen si va a + mejor o a peor; el signo, si el valor subió o bajó (un tiempo que baja mejora). + +
+ ); +} 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/ChangePill/ChangePill.tsx b/frontend/components/molecules/ChangePill/ChangePill.tsx new file mode 100644 index 0000000..04f2561 --- /dev/null +++ b/frontend/components/molecules/ChangePill/ChangePill.tsx @@ -0,0 +1,18 @@ +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 si fue a mejor (arriba) o a peor (abajo), y el color lo + * refuerza; hacia dónde se movió el número lo dice el signo del texto. Un tiempo que baja + * es «−0.4 s» con flecha arriba y 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/InfoTip/InfoTip.helper.ts b/frontend/components/molecules/InfoTip/InfoTip.helper.ts new file mode 100644 index 0000000..572b704 --- /dev/null +++ b/frontend/components/molecules/InfoTip/InfoTip.helper.ts @@ -0,0 +1,19 @@ +export type TipPosition = { left: number; top: number; side: "above" | "below" }; + +const GAP = 8; +const MARGIN = 8; + +/** Dónde poner la explicación: centrada sobre el icono y, si arriba no cabe, debajo. Nunca se sale + * de la ventana: se empuja hacia dentro con un margen. Todo en píxeles de la ventana. */ +export function placeTip( + anchor: { left: number; right: number; top: number; bottom: number }, + tip: { width: number; height: number }, + view: { width: number; height: number }, +): TipPosition { + const centered = (anchor.left + anchor.right) / 2 - tip.width / 2; + const left = Math.max(MARGIN, Math.min(centered, view.width - tip.width - MARGIN)); + + const above = anchor.top - GAP - tip.height; + if (above >= MARGIN) return { left, top: above, side: "above" }; + return { left, top: anchor.bottom + GAP, side: "below" }; +} diff --git a/frontend/components/molecules/InfoTip/InfoTip.module.css b/frontend/components/molecules/InfoTip/InfoTip.module.css new file mode 100644 index 0000000..4486191 --- /dev/null +++ b/frontend/components/molecules/InfoTip/InfoTip.module.css @@ -0,0 +1,35 @@ +.trigger { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; + height: 22px; + padding: 0; + color: var(--text-4); + background: none; + border: 0; + border-radius: var(--radius-pill); + cursor: help; +} +.trigger:hover, .trigger[aria-expanded="true"] { color: var(--text); background: var(--row-hover); } + +.tip { + position: fixed; + z-index: 1000; + width: max-content; + max-width: min(300px, calc(100vw - 16px)); + padding: 10px 14px; + font-size: 12.5px; + font-weight: 400; + line-height: 1.5; + text-align: left; + color: var(--on-select); + background: var(--select); + border-radius: var(--radius-1); + box-shadow: var(--elev-2); + pointer-events: none; +} +.tip p { margin: 0; } +.tip p + p { margin-top: 8px; } +.tip strong { font-weight: 600; } diff --git a/frontend/components/molecules/InfoTip/InfoTip.tsx b/frontend/components/molecules/InfoTip/InfoTip.tsx new file mode 100644 index 0000000..d41469c --- /dev/null +++ b/frontend/components/molecules/InfoTip/InfoTip.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { Icon } from "../../atoms"; +import { placeTip, type TipPosition } from "./InfoTip.helper"; +import styles from "./InfoTip.module.css"; + +type Props = { + /** Qué se explica, para el lector de pantalla: «Qué es la tasa de éxito». */ + label: string; + /** La explicación. Corta: dos o tres frases. */ + children: ReactNode; +}; + +/** Un icono «i» que explica lo que tiene al lado. Se abre con el ratón, con el foco del teclado y + * con un toque; se cierra al salir, con Escape o al tocar fuera, y sigue al icono si se scrollea. + * + * La explicación se pinta en un portal y con posición fija: las tarjetas recortan lo que se sale + * (`overflow`) y crean su propio contexto de posición (container queries), y una explicación + * cortada a medias es peor que ninguna. */ +export function InfoTip({ label, children }: Props) { + const id = useId(); + const button = useRef(null); + const tip = useRef(null); + const [open, setOpen] = useState(false); + const [pos, setPos] = useState(null); + + const place = () => { + if (!button.current || !tip.current) return; + setPos(placeTip( + button.current.getBoundingClientRect(), + { width: tip.current.offsetWidth, height: tip.current.offsetHeight }, + { width: window.innerWidth, height: window.innerHeight }, + )); + }; + + // Se mide después de pintarla (hasta entonces, invisible) para saber cuánto ocupa y dónde cabe. + useLayoutEffect(() => { if (open) place(); }, [open]); + + useEffect(() => { + if (!open) return; + const close = () => { setOpen(false); setPos(null); }; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; + const onPointer = (e: PointerEvent) => { + if (!button.current?.contains(e.target as Node)) close(); + }; + // Al scrollear (también al llegar con el teclado a un icono fuera de vista) el icono se mueve: + // la explicación lo sigue en vez de cerrarse o quedarse en el aire. + document.addEventListener("keydown", onKey); + document.addEventListener("pointerdown", onPointer); + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + document.removeEventListener("keydown", onKey); + document.removeEventListener("pointerdown", onPointer); + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [open]); + + const show = () => setOpen(true); + const hide = () => { setOpen(false); setPos(null); }; + + return ( + <> + + {open && createPortal( + , + document.body, + )} + + ); +} diff --git a/frontend/components/molecules/InfoTip/index.ts b/frontend/components/molecules/InfoTip/index.ts new file mode 100644 index 0000000..d08099a --- /dev/null +++ b/frontend/components/molecules/InfoTip/index.ts @@ -0,0 +1 @@ +export { InfoTip } from "./InfoTip"; diff --git a/frontend/components/molecules/MetricTrend/MetricTrend.module.css b/frontend/components/molecules/MetricTrend/MetricTrend.module.css new file mode 100644 index 0000000..3532779 --- /dev/null +++ b/frontend/components/molecules/MetricTrend/MetricTrend.module.css @@ -0,0 +1,18 @@ +.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); +} + +.change { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; } + +/* La línea se queda abajo aunque los mosaicos de una fila tengan alturas distintas. */ +.chart { margin-top: auto; padding-top: 10px; } + +/* El icono de ayuda pega con la etiqueta y no empuja el resto del mosaico. */ +.label { display: flex; align-items: center; gap: 2px; margin: -3px 0 -3px; } diff --git a/frontend/components/molecules/MetricTrend/MetricTrend.tsx b/frontend/components/molecules/MetricTrend/MetricTrend.tsx new file mode 100644 index 0000000..bfaa75d --- /dev/null +++ b/frontend/components/molecules/MetricTrend/MetricTrend.tsx @@ -0,0 +1,55 @@ +import { Text, Trend, Value, type TrendPoint } from "../../atoms"; +import { ChangePill } from "../ChangePill"; +import { InfoTip } from "../InfoTip"; +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; + /** Qué mide y cómo se lee, en dos o tres frases. */ + help?: 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, 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, help, value, unit, change, note, points, tone, dashed, chartLabel, +}: Props) { + return ( +
+
+ {label} + {help && {help}} +
+ + +
+ {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/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/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 new file mode 100644 index 0000000..e5c42c8 --- /dev/null +++ b/frontend/components/molecules/SegmentedControl/SegmentedControl.module.css @@ -0,0 +1,36 @@ +.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; } +/* El aro de foco lo dibuja la cara (redondeada); el del input, invisible, saldría duplicado. */ +.input:focus-visible { outline: none; } + +.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; } + +.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 new file mode 100644 index 0000000..efeecc9 --- /dev/null +++ b/frontend/components/molecules/SegmentedControl/SegmentedControl.tsx @@ -0,0 +1,37 @@ +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; + /** 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, compact }: 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/SnapshotView/SnapshotView.module.css b/frontend/components/molecules/SnapshotView/SnapshotView.module.css index cdb68fb..4ccf9e9 100644 --- a/frontend/components/molecules/SnapshotView/SnapshotView.module.css +++ b/frontend/components/molecules/SnapshotView/SnapshotView.module.css @@ -1,6 +1,39 @@ +/* El hueco que deja el panel. Es contenedor de tamaño: la foto se dimensiona con sus medidas + (cqw/cqh), así que crece hasta tocar un borde del hueco sin pasarse ni deformarse. */ +.area { + flex: 1; + align-self: stretch; + min-height: 0; + container-type: size; + display: flex; + align-items: center; + justify-content: center; +} .shot { + position: relative; + margin: 0; + width: min(100cqw, calc(100cqh * var(--ratio))); + aspect-ratio: var(--ratio); + overflow: hidden; + background: var(--row-alt); + border: 1px solid var(--border); + border-radius: var(--radius-2); +} +.image { + display: block; width: 100%; - height: auto; - max-height: 100%; - object-fit: contain; + height: 100%; + object-fit: cover; +} +/* Dice de dónde sale la imagen: pequeña y en una esquina, sobre la propia foto. */ +.tag { + position: absolute; + left: 8px; + bottom: 8px; + padding: 2px 9px; + font-size: 11px; + color: var(--text-3); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-pill); } diff --git a/frontend/components/molecules/SnapshotView/SnapshotView.tsx b/frontend/components/molecules/SnapshotView/SnapshotView.tsx index 18a94a1..65475a6 100644 --- a/frontend/components/molecules/SnapshotView/SnapshotView.tsx +++ b/frontend/components/molecules/SnapshotView/SnapshotView.tsx @@ -1,4 +1,5 @@ import Image from "next/image"; +import type { CSSProperties } from "react"; import type { Snapshot } from "@/lib/supabase"; import styles from "./SnapshotView.module.css"; @@ -6,16 +7,20 @@ type Props = { snapshot: Snapshot; alt: string }; /** La captura que dejó el simulador. Se enseña al lado del esquema para comprobar que * el dibujo no miente: si la foto y el esquema no coinciden, uno de los dos está mal y - * hasta ahora no había forma de saberlo. */ + * hasta ahora no había forma de saberlo. + * + * La foto es una tarjeta más: esquinas y borde como el resto de la interfaz, del mayor + * tamaño que cabe en el panel con su proporción, centrada y sin recortar ni dejar franjas. */ export function SnapshotView({ snapshot, alt }: Props) { + const width = snapshot.width ?? 800; + const height = snapshot.height ?? 600; return ( - {alt} +
+
+ {alt} +
captura del simulador
+
+
); } 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/VerdictRow/VerdictRow.tsx b/frontend/components/molecules/VerdictRow/VerdictRow.tsx new file mode 100644 index 0000000..575c501 --- /dev/null +++ b/frontend/components/molecules/VerdictRow/VerdictRow.tsx @@ -0,0 +1,30 @@ +import { Dot, Text } from "../../atoms"; +import styles from "./VerdictRow.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 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} + {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 1e49825..482ba2a 100644 --- a/frontend/components/molecules/index.ts +++ b/frontend/components/molecules/index.ts @@ -21,4 +21,11 @@ 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"; +export { ChangeLegend } from "./ChangeLegend"; +export { VerdictRow } from "./VerdictRow"; export { SnapshotView } from "./SnapshotView"; +export { InfoTip } from "./InfoTip"; diff --git a/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts b/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts index a68714b..b753ec6 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 = { @@ -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 }>; }; @@ -49,12 +51,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,12 +62,13 @@ 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), }; } 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) => )} diff --git a/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.tsx b/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.tsx index 750aa9b..f060ed8 100644 --- a/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.tsx +++ b/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from "react"; -import { Button, Text } from "../../atoms"; -import { LegendItem, ResultCard, SnapshotView } from "../../molecules"; +import { Text } from "../../atoms"; +import { LegendItem, ResultCard, SegmentedControl, SnapshotView } from "../../molecules"; import { EventFeed } from "../EventFeed"; import { IdentityBar } from "../IdentityBar"; import { KpiGrid } from "../KpiGrid"; @@ -29,9 +29,11 @@ export function EpisodeDashboard({ view, header, banner, picker }: Props) { const [shot, setShot] = useState(false); const hasShots = Boolean(view.shots.top || view.shots.side); const toggle = hasShots && ( - + setShot(v === "shot")} + /> ); return ( @@ -44,10 +46,12 @@ export function EpisodeDashboard({ view, header, banner, picker }: Props) {
- - - + aside={<> + {!(shot && view.shots.top) && <> + + + + } {toggle} } > diff --git a/frontend/components/organisms/LevelCard/LevelCard.helper.ts b/frontend/components/organisms/LevelCard/LevelCard.helper.ts new file mode 100644 index 0000000..edb9081 --- /dev/null +++ b/frontend/components/organisms/LevelCard/LevelCard.helper.ts @@ -0,0 +1,200 @@ +import { comparability, 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", +}; + +/** Qué significa cada clase de datos, para el icono de ayuda de su etiqueta. */ +export const KIND_HELP: Record = { + measured: "Ejecuciones medidas de verdad: el robot percibe, planifica y coloca por sí mismo.", + oracle: "La percepción se sustituye por las poses reales de los paquetes, así que estas ejecuciones " + + "no miden la visión. Nunca se mezclan con las medidas en una misma curva.", + synthetic: "Datos sembrados para poder ver la interfaz, no medidos. Nunca se mezclan con las " + + "medidas en una misma curva.", +}; + +export function kindOf(run: Run): DataKind { + return run.synthetic ? "synthetic" : run.oracle ? "oracle" : "measured"; +} + +export type MetricView = { + key: string; + label: string; + help: 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; + /** 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[]; +}; + +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; + /** Qué mide, cómo se calcula y qué es «mejor». */ + help: 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" }, + help: "De los episodios de cada ejecución, cuántos terminan con éxito: todos los paquetes " + + "colocados y sin ningún fallo. Cada punto de la curva es una ejecución. Más es mejor." }, + { 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", + help: "Lo que tarda el robot en colocar un paquete, en la mediana de la ejecución. Se usa la " + + "mediana y no la media: un episodio que se derrumba enseguida no debe hacer parecer " + + "rápida una ejecución que va mal. Menos es mejor." }, + { 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" }, + help: "Cuánto cae el centro de gravedad de la pila hacia dentro del polígono que la sostiene, " + + "en la mediana. Positivo, el centro está sobre el apoyo y la pila es estable; cuanto " + + "mayor, más holgura antes de volcar. Más es mejor." }, + { 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" }, + help: "Distancia, en el plano del palé, entre donde el plan quería cada paquete y donde acabó, " + + "en la mediana. Menos es mejor." }, +]; + +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, + // La flecha dice si ha ido a mejor o a peor, no hacia dónde se movió el número: un tiempo + // que sube es una flecha hacia abajo en rojo. Hacia dónde se movió lo dice el signo del texto. + direction: tone === "muted" ? "flat" : tone === "ok" ? "up" : "down", + text: `${signedNumber(diff, def.delta.digits)} ${def.delta.unit}`, + }; + } + + const n = runs.length; + return { + key: def.key, + label: def.label, + help: def.help, + 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 }; +} + +/** 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 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); + + return { + key: `${level}`, + title: `Nivel ${level}`, + meta: `${sorted.length} ${sorted.length === 1 ? "ejecución" : "ejecuciones"} · ` + + (from === to ? from : `${from} – ${to}`), + kind, + 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.module.css b/frontend/components/organisms/LevelCard/LevelCard.module.css new file mode 100644 index 0000000..4001d29 --- /dev/null +++ b/frontend/components/organisms/LevelCard/LevelCard.module.css @@ -0,0 +1,29 @@ +.card { min-width: 0; container-type: inline-size; } + +.header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px 16px; + padding: 24px 28px 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; } + +/* 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: 20px; + padding: 24px 28px 28px; +} +@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/LevelCard/LevelCard.tsx b/frontend/components/organisms/LevelCard/LevelCard.tsx new file mode 100644 index 0000000..60aa5c1 --- /dev/null +++ b/frontend/components/organisms/LevelCard/LevelCard.tsx @@ -0,0 +1,42 @@ +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 ejecuciones que se dejan fuera por no ser comparables. */ +export function LevelCard({ view }: { view: LevelCardView }) { + const dashed = view.kind !== "measured"; + return ( + +
+
+ {view.title} + + {[view.meta, view.seeds].filter(Boolean).join(" · ")} + +
+
+ {view.kind !== "measured" && ( + {KIND_TEXT[view.kind]} + )} + {view.excluded > 0 && ( + +{view.excluded} con otras semillas + )} + + {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..5c8f4bc --- /dev/null +++ b/frontend/components/organisms/LevelCard/index.ts @@ -0,0 +1,3 @@ +export { LevelCard } from "./LevelCard"; +export { buildLevelCard, kindOf, KIND_HELP, KIND_TEXT } from "./LevelCard.helper"; +export type { DataKind, LevelCardView, MetricView, Verdict } from "./LevelCard.helper"; diff --git a/frontend/components/organisms/OverviewPanel/OverviewPanel.helper.ts b/frontend/components/organisms/OverviewPanel/OverviewPanel.helper.ts new file mode 100644 index 0000000..9de1a3a --- /dev/null +++ b/frontend/components/organisms/OverviewPanel/OverviewPanel.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/OverviewPanel/OverviewPanel.module.css b/frontend/components/organisms/OverviewPanel/OverviewPanel.module.css new file mode 100644 index 0000000..e96a5d2 --- /dev/null +++ b/frontend/components/organisms/OverviewPanel/OverviewPanel.module.css @@ -0,0 +1,8 @@ +.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; } + +.head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } diff --git a/frontend/components/organisms/OverviewPanel/OverviewPanel.tsx b/frontend/components/organisms/OverviewPanel/OverviewPanel.tsx new file mode 100644 index 0000000..574bfe9 --- /dev/null +++ b/frontend/components/organisms/OverviewPanel/OverviewPanel.tsx @@ -0,0 +1,40 @@ +import { Card, Text } from "../../atoms"; +import { InfoTip, 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 + + Un veredicto por cada nivel: cuántas de sus métricas van a mejor o a peor desde la primera + ejecución. Pulsa una fila para ir a su tarjeta, con las curvas. + +
+
+ {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/TaskSection/TaskSection.module.css b/frontend/components/organisms/TaskSection/TaskSection.module.css new file mode 100644 index 0000000..572f110 --- /dev/null +++ b/frontend/components/organisms/TaskSection/TaskSection.module.css @@ -0,0 +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: 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: 32px; } diff --git a/frontend/components/organisms/TaskSection/TaskSection.tsx b/frontend/components/organisms/TaskSection/TaskSection.tsx new file mode 100644 index 0000000..6b02b35 --- /dev/null +++ b/frontend/components/organisms/TaskSection/TaskSection.tsx @@ -0,0 +1,36 @@ +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; + /** «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..32e7efe --- /dev/null +++ b/frontend/components/organisms/TaskSection/index.ts @@ -0,0 +1,2 @@ +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 d1a0e60..d007bcd 100644 --- a/frontend/components/organisms/index.ts +++ b/frontend/components/organisms/index.ts @@ -17,3 +17,8 @@ export { TopBar } from "./TopBar"; export { EpisodeDashboard, buildEpisodeView } from "./EpisodeDashboard"; export type { EpisodeView } from "./EpisodeDashboard"; export { RunHeader, buildRunHeader } from "./RunHeader"; +export { LevelCard, buildLevelCard, kindOf, KIND_HELP, KIND_TEXT } from "./LevelCard"; +export type { DataKind, LevelCardView } from "./LevelCard"; +export { TaskSection, anchorOf } from "./TaskSection"; +export type { TaskSectionView } from "./TaskSection"; +export { OverviewPanel } from "./OverviewPanel"; 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..591da67 --- /dev/null +++ b/frontend/components/screens/DashboardScreen/DashboardScreen.module.css @@ -0,0 +1,71 @@ +/* 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: 36px; + 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; +} +.controls { display: flex; align-items: center; gap: 6px; } +.titles { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.title { font-size: 28px; font-weight: 700; } + +/* 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: 40px; + align-items: start; +} +.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 { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + gap: 28px; +} +.guide { padding: 22px 28px; gap: 12px; } +.note { line-height: 1.5; } + +/* 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); 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; + 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: 24px; } + .content { gap: 36px; } +} diff --git a/frontend/components/screens/DashboardScreen/DashboardScreen.tsx b/frontend/components/screens/DashboardScreen/DashboardScreen.tsx new file mode 100644 index 0000000..f4245f2 --- /dev/null +++ b/frontend/components/screens/DashboardScreen/DashboardScreen.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { Button, Card, Text } from "../../atoms"; +import { + ChangeLegend, EmptyState, InfoMessage, InfoTip, Notice, SegmentedControl, +} from "../../molecules"; +import { KIND_HELP, 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. + * 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(); + + 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, + }))} + /> + + {(["measured", "oracle", "synthetic"] as const).map((k) => ( +

{KIND_TEXT[k]}. {KIND_HELP[k]}

+ ))} +
+
+
+ +
+
+ {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/components/screens/LiveScreen/LiveScreen.helper.ts b/frontend/components/screens/LiveScreen/LiveScreen.helper.ts index a7b3d42..f458cd0 100644 --- a/frontend/components/screens/LiveScreen/LiveScreen.helper.ts +++ b/frontend/components/screens/LiveScreen/LiveScreen.helper.ts @@ -1,13 +1,12 @@ import { clockTime } from "@/lib/ui"; -/** Sin nada recibido durante este tiempo, con un episodio en curso, se avisa. Generoso a - * propósito: un aviso en falso durante la demo es peor que enterarse más tarde. - * - * Estaba en 8 s y saltaba a media ejecución sin que pasara nada. El ritmo de subida es - * muy irregular —el mismo episodio de 204 s simulados tarda entre 26 y 71 s de reloj, - * o sea entre x2.9 y x7.9 con el mismo motion_speed declarado—, así que hay huecos - * largos entre filas que son perfectamente normales. */ -export const HEARTBEAT_MS = 20_000; +/** 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: el ritmo de subida es muy irregular (el mismo episodio de 204 s simulados + * tarda entre 26 y 71 s de reloj con el mismo motion_speed), así que hay huecos largos entre + * filas que son normales. Un 8 s saltaba a media ejecución sin que pasara nada. */ +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 @@ -19,23 +18,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; -/** El aviso que toca, que NO siempre es "conexión perdida". - * - * Con el canal suscrito la conexión está perfectamente: lo que pasa es que no llegan - * filas. Decir "conexión perdida" entonces manda a mirar el wifi cuando el problema - * está en el productor, y es justo el minuto que no sobra en una demo. */ -export function staleMessage(input: { subscribed: boolean; fetchFailed: boolean }): string { - if (input.fetchFailed || !input.subscribed) return "Conexión perdida. Reintentando."; - return "Sin datos nuevos."; +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 ed39602..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 84de475..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,9 +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, staleMessage, -} 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. * @@ -40,13 +38,12 @@ export function useLiveScreen() { }, [holding, refetchEpisodes]); const detail = useEpisodeDetail(episode?.id); - const { lastReceivedAt, subscribed } = 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(); }; @@ -59,20 +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 fetchFailed = live.isError || detail.isError; - const stale = isStale({ - running: episode.status === "running", fetchFailed, lastSignalAt, now, + // 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, channelError, lastSignalAt, now, }); + const stale = kind !== null; return { state: "ready" as const, retry, stale, + silent: kind === "silent", finished: holding, - staleMessage: staleMessage({ subscribed, fetchFailed }), - staleDetail: staleDetail(lastSignalAt), + staleDetail: staleDetail(kind, lastSignalAt), view: buildEpisodeView({ episode, placements: detail.placements.data ?? [], 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}`, diff --git a/frontend/lib/ui.ts b/frontend/lib/ui.ts index 78805ba..1ad7a83 100644 --- a/frontend/lib/ui.ts +++ b/frontend/lib/ui.ts @@ -86,6 +86,28 @@ 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"; +} + +/** 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 "—"; + 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 } { 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);