diff --git a/AGENTS.md b/AGENTS.md index 4edab9a..dcdbf04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,9 +37,9 @@ 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] - api/clients/ una función por endpoint de PostgREST - api/hooks/ los mismos, envueltos en TanStack Query + app/ rutas: / (Resumen), /live, /runs, /runs/[id], /runs/[id]/[seed] + lib/api/clients/ una función por endpoint de PostgREST + lib/api/hooks/ los mismos, envueltos en TanStack Query components/ atoms / organisms / screens lib/ui.ts TODA conversión de unidades, en un solo sitio styles/colors.css TODOS los colores (tema claro y oscuro), en un solo sitio @@ -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/README.md b/README.md index 57f573f..647aa44 100644 --- a/README.md +++ b/README.md @@ -300,8 +300,8 @@ backend/ SQL + SDK. Supabase IS the backend; there is no server frontend/ Next.js 16. Reads Supabase with the anon key, under RLS. app/ routes: / (Live), /runs, /runs/[id], /runs/[id]/[seed] - api/clients/ one function per PostgREST endpoint - api/hooks/ the same, wrapped in TanStack Query + lib/api/clients/ one function per PostgREST endpoint + lib/api/hooks/ the same, wrapped in TanStack Query components/ atoms / molecules / organisms / screens lib/ui.ts ALL unit conversion, in one place lib/pallet.ts drawing geometry; the pallet size is NOT fixed diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py index 3694d66..cf51199 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -5,6 +5,7 @@ que significa: negativo = vuelca. """ +import math import os import random import subprocess @@ -18,6 +19,7 @@ from theker_telemetry.pallet import ( TRANSPORT_ACCEL_G, stability_margin, + support_hull, support_polygon, ) @@ -40,6 +42,45 @@ def test_una_sola_caja_en_una_esquina_apoya_poco(): assert round(y1 - y0, 3) == 0.380 +# ── cajas giradas ──────────────────────────────────────────────────────────── +# El paletizado gira paquetes a propósito (90° para que entren en la fila). Sin esto, +# `dims_m` se leía como si siempre estuviera alineado con los ejes y una caja de canto +# apoyaba su lado largo: el apoyo salía más grande de lo que es. + +def test_girar_90_grados_intercambia_los_lados_del_apoyo(): + recta = support_polygon([(0.0, 0.0, 0.48, 0.34)]) + canto = support_polygon([(0.0, 0.0, 0.48, 0.34, math.pi / 2)]) + assert (round(recta[1] - recta[0], 3), round(recta[3] - recta[2], 3)) == (0.48, 0.34) + assert (round(canto[1] - canto[0], 3), round(canto[3] - canto[2], 3)) == (0.34, 0.48) + + +def test_una_caja_sin_yaw_se_sigue_escribiendo_con_cuatro_numeros(): + """La tupla de 5 es opcional: el sembrado y la simulación de hoy no cambian.""" + assert support_polygon([(0.0, 0.0, 0.48, 0.34)]) == \ + support_polygon([(0.0, 0.0, 0.48, 0.34, 0.0)]) + + +def test_el_apoyo_girado_no_regala_las_esquinas(): + """Con una caja a 45° el rectángulo envolvente incluye esquinas donde no apoya nada. + + Es la diferencia entre un margen optimista y uno cierto: en este CoG el envolvente + diría +174 mm (estable) y el apoyo real dice que vuelca. + """ + rombo = [(0.0, 0.0, 0.6, 0.6, math.pi / 4)] + x0, x1, y0, y1 = support_polygon(rombo) + por_el_envolvente = min(0.25 - x0, x1 - 0.25, 0.25 - y0, y1 - 0.25) + assert por_el_envolvente > 0 + assert stability_margin(0.25, 0.25, 0.0, rombo) < 0 + + +def test_el_casco_de_una_caja_girada_tiene_sus_cuatro_esquinas(): + hull = support_hull([(0.0, 0.0, 0.6, 0.6, math.pi / 4)]) + assert len(hull) == 4 + # A 45° las esquinas caen sobre los ejes, a media diagonal del cuadrado. + d = 0.6 * math.sqrt(2) / 2 + assert sorted(round(math.dist((0.0, 0.0), p), 4) for p in hull) == [round(d, 4)] * 4 + + # ── el margen de estabilidad ───────────────────────────────────────────────── def test_sin_base_no_hay_margen(): diff --git a/backend/theker_telemetry/__init__.py b/backend/theker_telemetry/__init__.py index e426ebe..dd3627d 100644 --- a/backend/theker_telemetry/__init__.py +++ b/backend/theker_telemetry/__init__.py @@ -18,7 +18,9 @@ from .core import RunLog, Supabase, episode_row, load_env from .pallet import ( TRANSPORT_ACCEL_G, + corners, stability_margin, + support_hull, support_polygon, ) from .schema import FAILURES, TASKS, EpisodeResult, RunWriter, git_sha @@ -26,5 +28,5 @@ __all__ = [ "EpisodeResult", "RunWriter", "RunLog", "Supabase", "FAILURES", "TASKS", "git_sha", "episode_row", "load_env", - "TRANSPORT_ACCEL_G", "stability_margin", "support_polygon", + "TRANSPORT_ACCEL_G", "corners", "stability_margin", "support_hull", "support_polygon", ] diff --git a/backend/theker_telemetry/pallet.py b/backend/theker_telemetry/pallet.py index d095b63..c985f43 100644 --- a/backend/theker_telemetry/pallet.py +++ b/backend/theker_telemetry/pallet.py @@ -10,6 +10,8 @@ from __future__ import annotations +import math + # Aceleración lateral que se exige aguantar, en g. El caso no es un palé quieto: un # montón vuelca frenando o girando. Con aceleración lateral `a`, cae si # @@ -20,21 +22,66 @@ # compromiso que el planificador tiene que resolver. TRANSPORT_ACCEL_G = 0.28 -# Caja = (x, y, ancho_x, ancho_y), en metros y en el frame del palé. -Caja = tuple[float, float, float, float] +# Caja = (x, y, ancho_x, ancho_y) o (x, y, ancho_x, ancho_y, yaw), en metros y radianes, +# en el frame del palé. El `yaw` es opcional y vale 0 si no viene: una caja sin girar se +# sigue escribiendo con cuatro números. +Caja = tuple[float, ...] + +Punto = tuple[float, float] + + +def corners(caja: Caja) -> list[Punto]: + """Las cuatro esquinas de la caja en planta, ya giradas por su `yaw`. + + El paletizado gira paquetes a propósito —90° para que entren en la fila—, así que + tratar `(dx, dy)` como si siempre estuviera alineado con los ejes se come el giro: + una caja de 480x340 puesta de canto apoya 340 de ancho, no 480. + """ + x, y, dx, dy = caja[0], caja[1], caja[2], caja[3] + yaw = caja[4] if len(caja) > 4 else 0.0 + cos, sin = math.cos(yaw), math.sin(yaw) + return [(x + u * cos - v * sin, y + u * sin + v * cos) + for u, v in ((-dx / 2, -dy / 2), (dx / 2, -dy / 2), + (dx / 2, dy / 2), (-dx / 2, dy / 2))] + + +def _cross(o: Punto, a: Punto, b: Punto) -> float: + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + + +def support_hull(base: list[Caja]) -> list[Punto]: + """Polígono de apoyo de verdad: la envolvente CONVEXA de las esquinas de la capa 1. + + Convexa y no rectangular porque con cajas giradas el rectángulo envolvente incluye + esquinas donde no apoya nada, y eso da un margen optimista: la pantalla diría que + todo va bien hasta el derrumbe, que es justo lo que este indicador existe para + evitar. Sale en orden antihorario, que es lo que asume `stability_margin`. + """ + pts = sorted({p for caja in base for p in corners(caja)}) + if len(pts) < 3: + return pts + + def media(ordenados: list[Punto]) -> list[Punto]: + out: list[Punto] = [] + for p in ordenados: + while len(out) >= 2 and _cross(out[-2], out[-1], p) <= 0: + out.pop() + out.append(p) + return out + + return media(pts)[:-1] + media(pts[::-1])[:-1] def support_polygon(base: list[Caja]) -> tuple[float, float, float, float]: - """Rectángulo que apoya en el palé: la envolvente de las cajas de la capa 1. + """Rectángulo que encierra el apoyo: `(x0, x1, y0, y1)`, con los giros ya aplicados. - Lo que sostiene el montón es lo que toca el suelo, no el palé entero. Un palé con - una sola caja en una esquina tiene un apoyo pequeño aunque la tabla sea enorme. + Es la caja envolvente del apoyo, útil para encuadrar y para medir vuelo (overhang). + Para el margen de estabilidad se usa `support_hull`, que no regala las esquinas. """ - xs0 = [x - dx / 2 for x, _, dx, _ in base] - xs1 = [x + dx / 2 for x, _, dx, _ in base] - ys0 = [y - dy / 2 for _, y, _, dy in base] - ys1 = [y + dy / 2 for _, y, _, dy in base] - return min(xs0), max(xs1), min(ys0), max(ys1) + pts = [p for caja in base for p in corners(caja)] + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + return min(xs), max(xs), min(ys), max(ys) def stability_margin(cog_x: float, cog_y: float, cog_z: float, @@ -47,6 +94,14 @@ def stability_margin(cog_x: float, cog_y: float, cog_z: float, """ if not base: return 0.0 - x0, x1, y0, y1 = support_polygon(base) - d_edge = min(cog_x - x0, x1 - cog_x, cog_y - y0, y1 - cog_y) + hull = support_hull(base) + if len(hull) < 3: # apoyo degenerado (sin área): no sostiene nada + return 0.0 + + # Distancia con signo del CoG a cada lado; en un polígono antihorario es positiva + # por dentro. La menor es la que manda: el montón cae por su lado más flojo. + d_edge = min( + _cross(a, b, (cog_x, cog_y)) / math.dist(a, b) + for a, b in zip(hull, hull[1:] + hull[:1]) + ) return d_edge - accel_g * cog_z diff --git a/docs/API.md b/docs/API.md index d1d7c80..fe18544 100644 --- a/docs/API.md +++ b/docs/API.md @@ -5,8 +5,8 @@ PostgREST, más los canales Realtime. Este documento sale de `backend/sql/001_sc `backend/sql/002_design.sql` y `backend/theker_telemetry/core.py`; las respuestas de ejemplo son la forma de las columnas, no capturas de una base real. -Los clientes del front que los consumen están en `frontend/api/clients/` y los hooks en -`frontend/api/hooks/`. Los tipos siguen en `frontend/lib/supabase.ts`, que es lo que +Los clientes del front que los consumen están en `frontend/lib/api/clients/` y los hooks en +`frontend/lib/api/hooks/`. Los tipos siguen en `frontend/lib/supabase.ts`, que es lo que compara `backend/tests/test_contrato.py` contra las columnas de verdad. ## 1. Convenciones @@ -168,6 +168,10 @@ Solo aparecen causas con al menos un episodio: un run sin fallos devuelve `[]`. `planned_pose`/`actual_pose` son jsonb y pueden ser `null`. `dims_m` es `[largo, ancho, alto]`. Las tres poses/dimensiones están en metros y radianes. +El `yaw` de la pose **se dibuja**: `dims_m` va en el eje del paquete, no en el del palé, así que +un paquete girado 90° ocupa `ancho` en X y `largo` en Y. Quien produce episodios no tiene que +premasticarlo —no hay que intercambiar `dims_m`—: basta con mandar el `yaw` de verdad. + ### 2.5 `GET /pallet_states`: traza del centro de gravedad `?select=*&episode_id=eq.{uuid}&order=after_seq.asc` → `getPalletStates(id)` / `usePalletStates(id)` 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/SceneRect/SceneRect.tsx b/frontend/components/atoms/SceneRect/SceneRect.tsx index 4ff99b5..5501924 100644 --- a/frontend/components/atoms/SceneRect/SceneRect.tsx +++ b/frontend/components/atoms/SceneRect/SceneRect.tsx @@ -9,10 +9,16 @@ type Props = { variant: SceneRectVariant; /** Solo lo usa `support`: toma el color del estado de estabilidad. */ tone?: "ok" | "warn" | "bad"; + /** Giro del paquete, ya en grados de pantalla y alrededor de su centro. Lo calcula + * quien sabe de metros; aquí solo se aplica. */ + rotate?: { deg: number; cx: number; cy: number }; }; /** Un rectángulo del dibujo del palé. Coordenadas ya en píxeles: no sabe de metros. */ -export function SceneRect({ variant, tone = "ok", ...box }: Props) { +export function SceneRect({ variant, tone = "ok", rotate, ...box }: Props) { const rx = variant === "frame" || variant === "deck" ? 8 : 3; - return ; + return ( + + ); } 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..163433e 100644 --- a/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts +++ b/frontend/components/organisms/ComparePanel/ComparePanel.helper.ts @@ -1,8 +1,8 @@ import type { Blocker, Run } from "@/lib/supabase"; import { commonSeeds, comparability } from "@/lib/supabase"; -import type { FailureBreakdownRow } from "@/api/types"; +import type { FailureBreakdownRow } from "@/lib/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..0a85151 --- /dev/null +++ b/frontend/components/organisms/LevelCard/LevelCard.module.css @@ -0,0 +1,41 @@ +.card { min-width: 0; container-type: inline-size; } + +/* La cabecera es el resumen plegable: se ve siempre, abierta o cerrada. */ +.header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 16px; + padding: 24px 28px; + cursor: pointer; + list-style: none; /* el triángulo del navegador sobra: hay chevron propio */ +} +.header::-webkit-details-marker { display: none; } +.header:focus-visible { outline: 2px solid var(--select); outline-offset: -2px; } + +/* Decorativo: quien dice si está plegada es el propio `
`. */ +.chevron { display: inline-flex; color: var(--text-5); flex: none; } +.details[open] .chevron { transform: rotate(90deg); } +@media (prefers-reduced-motion: no-preference) { + .chevron { transition: transform 120ms ease; } +} + +.title { display: flex; align-items: baseline; gap: 12px; min-width: 0; flex-wrap: wrap; flex: 1; } +.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: 0 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..76ce3cf --- /dev/null +++ b/frontend/components/organisms/LevelCard/LevelCard.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from "react"; +import { Badge, Card, Icon, 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. + * + * Las métricas van plegadas: los niveles se cuentan por decenas y desplegarlos todos deja el + * veredicto de cada uno a una pantalla del siguiente, que es justo lo que esta pantalla existe + * para comparar. Plegada, la tarjeta sigue diciendo lo que importa: qué nivel es, con cuántas + * ejecuciones y cómo va. Se usa `
` y no un desplegable propio porque trae hechos el + * teclado, el foco y el «buscar en la página». + * + * El estado se guarda aquí, y no se deja suelto en el DOM, porque la pantalla refresca sus datos + * sola: sin esto, un refetch volvería a plegar la tarjeta que acabas de abrir. */ +export function LevelCard({ view, defaultOpen = false }: { + view: LevelCardView; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + const dashed = view.kind !== "measured"; + return ( + +
setOpen(e.currentTarget.open)}> + + +
+ {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..5461d10 --- /dev/null +++ b/frontend/components/organisms/OverviewPanel/OverviewPanel.helper.ts @@ -0,0 +1,13 @@ +/** 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 { + const card = document.getElementById(id); + if (!card) return; + // La tarjeta puede estar plegada, y llevar a una cabecera cerrada no es llevar a ningún + // sitio: lo que se ha pedido son sus curvas. Abrirla dispara el `toggle` del `
`, + // así que el componente se entera y no vuelve a plegarla en el siguiente refresco. + const detalle = card.querySelector("details"); + if (detalle) detalle.open = true; + card.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/PalletSideView/PalletSideView.helper.ts b/frontend/components/organisms/PalletSideView/PalletSideView.helper.ts index ae2b327..4595415 100644 --- a/frontend/components/organisms/PalletSideView/PalletSideView.helper.ts +++ b/frontend/components/organisms/PalletSideView/PalletSideView.helper.ts @@ -1,6 +1,6 @@ import type { PalletState, Placement } from "@/lib/supabase"; import { - PALLET_DEFAULT, deckHeight, drawable, lastPlacement, loadHeight, minStackHeight, + PALLET_DEFAULT, deckHeight, drawable, lastPlacement, loadHeight, minStackHeight, span, type PalletSize, } from "@/lib/pallet"; import { mm, stabilityState, type State } from "@/lib/ui"; @@ -43,15 +43,20 @@ export function buildSideView( const last = lastPlacement(placements); const right = X(PX / 2); - const packages = items.map((p) => ({ - key: p.seq, - x: X(p.actual_pose.x - p.dims_m[0] / 2), - y: Z(p.actual_pose.z + p.dims_m[2] / 2), - width: p.dims_m[0] * scale, - height: p.dims_m[2] * scale, - variant: (!p.placed ? "failed" : p.seq === last?.seq ? "last" : "placed") as - "placed" | "last" | "failed", - })); + const packages = items.map((p) => { + // De perfil se ve la silueta del paquete YA girado: uno de canto es estrecho. El + // alto no cambia, porque el giro es sobre el eje vertical. + const ancho = span(p.dims_m[0], p.dims_m[1], p.actual_pose.yaw); + return { + key: p.seq, + x: X(p.actual_pose.x - ancho / 2), + y: Z(p.actual_pose.z + p.dims_m[2] / 2), + width: ancho * scale, + height: p.dims_m[2] * scale, + variant: (!p.placed ? "failed" : p.seq === last?.seq ? "last" : "placed") as + "placed" | "last" | "failed", + }; + }); return { width, height, diff --git a/frontend/components/organisms/PalletTopView/PalletTopView.helper.ts b/frontend/components/organisms/PalletTopView/PalletTopView.helper.ts index ed7cdab..93a1c56 100644 --- a/frontend/components/organisms/PalletTopView/PalletTopView.helper.ts +++ b/frontend/components/organisms/PalletTopView/PalletTopView.helper.ts @@ -1,6 +1,6 @@ import type { PalletState, Placement } from "@/lib/supabase"; import { - PALLET_DEFAULT, drawable, envelope, lastPlacement, + PALLET_DEFAULT, drawable, envelope, lastPlacement, span, type Box, type PalletSize, } from "@/lib/pallet"; import { mm, signedMm, stabilityState, type State } from "@/lib/ui"; @@ -9,14 +9,19 @@ const PAD = { l: 46, r: 24, t: 34, b: 40 }; type Rect = { x: number; y: number; width: number; height: number }; type Label = { x: number; y: number; text: string }; +/** Giro ya en grados de pantalla, alrededor del centro del paquete. */ +type Spin = { deg: number; cx: number; cy: number }; export type TopViewModel = { width: number; height: number; frame: Rect; /** El hueco planificado del último paquete: enseña cuánto se desvió. */ - planned: Rect | null; - packages: Array; + planned: (Rect & { rotate?: Spin }) | null; + packages: Array; support: Rect | null; cog: { x: number; y: number } | null; tone: State; @@ -44,6 +49,11 @@ export function buildTopView( const Y = (y: number) => PAD.t + (PY / 2 - y) * scale; const rectOf = (cx: number, cy: number, dx: number, dy: number): Rect => ({ x: X(cx - dx / 2), y: Y(cy + dy / 2), width: dx * scale, height: dy * scale }); + /* El paquete se dibuja sin girar y se gira con un `transform`, que es lo que mantiene + el rectángulo (y su radio de esquina) intacto. +Y del mundo sube en pantalla, así + que un giro antihorario en el palé se ve horario: el ángulo va cambiado de signo. */ + const spin = (cx: number, cy: number, yaw: number | undefined): Spin | undefined => + yaw ? { deg: (-yaw * 180) / Math.PI, cx: X(cx), cy: Y(cy) } : undefined; const boxRect = (b: Box): Rect => ({ x: X(b.x0), y: Y(b.y1), width: (b.x1 - b.x0) * scale, height: (b.y1 - b.y0) * scale }); @@ -56,11 +66,17 @@ export function buildTopView( .sort((a, b) => (a.layer ?? 0) - (b.layer ?? 0) || a.seq - b.seq) .map((p) => { const isLast = p.seq === last?.seq; + const { x, y, yaw } = p.actual_pose; + const [dx, dy] = p.dims_m; return { key: p.seq, - ...rectOf(p.actual_pose.x, p.actual_pose.y, p.dims_m[0], p.dims_m[1]), + ...rectOf(x, y, dx, dy), + rotate: spin(x, y, yaw), variant: (!p.placed ? "failed" : isLast ? "last" : "placed") as "placed" | "last" | "failed", - label: isLast ? p.package_id : null, + // La etiqueta va sobre el paquete YA girado: de canto ocupa otro alto. + label: isLast + ? { x: X(x), y: Y(y + span(dy, dx, yaw) / 2) - 7, text: p.package_id } + : null, }; }); @@ -75,7 +91,10 @@ export function buildTopView( width, height, frame: boxRect({ x0: -PX / 2, x1: PX / 2, y0: -PY / 2, y1: PY / 2 }), planned: last?.planned_pose && last.dims_m - ? rectOf(last.planned_pose.x, last.planned_pose.y, last.dims_m[0], last.dims_m[1]) + ? { + ...rectOf(last.planned_pose.x, last.planned_pose.y, last.dims_m[0], last.dims_m[1]), + rotate: spin(last.planned_pose.x, last.planned_pose.y, last.planned_pose.yaw), + } : null, packages, support: below ? boxRect(below) : null, diff --git a/frontend/components/organisms/PalletTopView/PalletTopView.tsx b/frontend/components/organisms/PalletTopView/PalletTopView.tsx index cdf0615..5f57975 100644 --- a/frontend/components/organisms/PalletTopView/PalletTopView.tsx +++ b/frontend/components/organisms/PalletTopView/PalletTopView.tsx @@ -25,9 +25,7 @@ export function PalletTopView({ placements, state, {v.packages.map(({ key, label, variant, ...box }) => ( - {label && ( - - )} + {label && } ))} 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..858af49 --- /dev/null +++ b/frontend/components/organisms/TaskSection/TaskSection.tsx @@ -0,0 +1,38 @@ +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, i) => ( +
+ {/* El primero abierto: la pantalla entra enseñando métricas, no una lista de + cabeceras, y los demás quedan a un clic. */} + +
+ ))} +
+
+ ); +} 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/AppHeader/AppHeader.tsx b/frontend/components/screens/AppHeader/AppHeader.tsx index cd19665..132c2f2 100644 --- a/frontend/components/screens/AppHeader/AppHeader.tsx +++ b/frontend/components/screens/AppHeader/AppHeader.tsx @@ -1,7 +1,7 @@ "use client"; import { usePathname } from "next/navigation"; -import { useRunsTotals } from "@/api/hooks"; +import { useRunsTotals } from "@/lib/api/hooks"; import { useTheme } from "@/hooks/useTheme"; import { ThemeToggle } from "../../molecules"; import { RunsTotals, TopBar } from "../../organisms"; 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..5e1e8dd --- /dev/null +++ b/frontend/components/screens/DashboardScreen/useDashboardScreen.ts @@ -0,0 +1,40 @@ +import { useState } from "react"; +import { useRuns } from "@/lib/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..81a2cc6 100644 --- a/frontend/components/screens/LiveScreen/useLiveScreen.ts +++ b/frontend/components/screens/LiveScreen/useLiveScreen.ts @@ -1,15 +1,13 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useEpisodeDetail, useEpisodeRealtime, useEpisodes, useRunningEpisode, useRunningEpisodeRealtime, useSnapshots, -} from "@/api/hooks"; +} from "@/lib/api/hooks"; 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/components/screens/RunDetailScreen/useRunDetailScreen.ts b/frontend/components/screens/RunDetailScreen/useRunDetailScreen.ts index e49036d..09dd38e 100644 --- a/frontend/components/screens/RunDetailScreen/useRunDetailScreen.ts +++ b/frontend/components/screens/RunDetailScreen/useRunDetailScreen.ts @@ -1,7 +1,7 @@ import { useRouter } from "next/navigation"; import { useEpisodeDetail, useEpisodes, useRun, useSnapshots, -} from "@/api/hooks"; +} from "@/lib/api/hooks"; import { configured } from "@/lib/supabase"; import { routes } from "@/lib/routes"; import { buildEpisodeView } from "../../organisms/EpisodeDashboard"; diff --git a/frontend/components/screens/RunsScreen/useRunsScreen.ts b/frontend/components/screens/RunsScreen/useRunsScreen.ts index 206a198..11cd22d 100644 --- a/frontend/components/screens/RunsScreen/useRunsScreen.ts +++ b/frontend/components/screens/RunsScreen/useRunsScreen.ts @@ -1,6 +1,6 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { useFailureBreakdown, useRuns } from "@/api/hooks"; +import { useFailureBreakdown, useRuns } from "@/lib/api/hooks"; import { configured } from "@/lib/supabase"; import { routes } from "@/lib/routes"; import { diff --git a/frontend/api/clients/episodeDetail/getEvents.ts b/frontend/lib/api/clients/episodeDetail/getEvents.ts similarity index 100% rename from frontend/api/clients/episodeDetail/getEvents.ts rename to frontend/lib/api/clients/episodeDetail/getEvents.ts diff --git a/frontend/api/clients/episodeDetail/getPalletStates.ts b/frontend/lib/api/clients/episodeDetail/getPalletStates.ts similarity index 100% rename from frontend/api/clients/episodeDetail/getPalletStates.ts rename to frontend/lib/api/clients/episodeDetail/getPalletStates.ts diff --git a/frontend/api/clients/episodeDetail/getPlacements.ts b/frontend/lib/api/clients/episodeDetail/getPlacements.ts similarity index 100% rename from frontend/api/clients/episodeDetail/getPlacements.ts rename to frontend/lib/api/clients/episodeDetail/getPlacements.ts diff --git a/frontend/api/clients/episodeDetail/index.ts b/frontend/lib/api/clients/episodeDetail/index.ts similarity index 100% rename from frontend/api/clients/episodeDetail/index.ts rename to frontend/lib/api/clients/episodeDetail/index.ts diff --git a/frontend/api/clients/episodes/getEpisode.ts b/frontend/lib/api/clients/episodes/getEpisode.ts similarity index 100% rename from frontend/api/clients/episodes/getEpisode.ts rename to frontend/lib/api/clients/episodes/getEpisode.ts diff --git a/frontend/api/clients/episodes/getEpisodes.ts b/frontend/lib/api/clients/episodes/getEpisodes.ts similarity index 100% rename from frontend/api/clients/episodes/getEpisodes.ts rename to frontend/lib/api/clients/episodes/getEpisodes.ts diff --git a/frontend/api/clients/episodes/getRunningEpisode.ts b/frontend/lib/api/clients/episodes/getRunningEpisode.ts similarity index 100% rename from frontend/api/clients/episodes/getRunningEpisode.ts rename to frontend/lib/api/clients/episodes/getRunningEpisode.ts diff --git a/frontend/api/clients/episodes/index.ts b/frontend/lib/api/clients/episodes/index.ts similarity index 100% rename from frontend/api/clients/episodes/index.ts rename to frontend/lib/api/clients/episodes/index.ts diff --git a/frontend/api/clients/errors/ApiError.ts b/frontend/lib/api/clients/errors/ApiError.ts similarity index 100% rename from frontend/api/clients/errors/ApiError.ts rename to frontend/lib/api/clients/errors/ApiError.ts diff --git a/frontend/api/clients/errors/assertConfigured.ts b/frontend/lib/api/clients/errors/assertConfigured.ts similarity index 100% rename from frontend/api/clients/errors/assertConfigured.ts rename to frontend/lib/api/clients/errors/assertConfigured.ts diff --git a/frontend/api/clients/errors/index.ts b/frontend/lib/api/clients/errors/index.ts similarity index 100% rename from frontend/api/clients/errors/index.ts rename to frontend/lib/api/clients/errors/index.ts diff --git a/frontend/api/clients/errors/toApiError.ts b/frontend/lib/api/clients/errors/toApiError.ts similarity index 100% rename from frontend/api/clients/errors/toApiError.ts rename to frontend/lib/api/clients/errors/toApiError.ts diff --git a/frontend/api/clients/errors/trace.ts b/frontend/lib/api/clients/errors/trace.ts similarity index 100% rename from frontend/api/clients/errors/trace.ts rename to frontend/lib/api/clients/errors/trace.ts diff --git a/frontend/api/clients/failures/getFailureBreakdown.ts b/frontend/lib/api/clients/failures/getFailureBreakdown.ts similarity index 100% rename from frontend/api/clients/failures/getFailureBreakdown.ts rename to frontend/lib/api/clients/failures/getFailureBreakdown.ts diff --git a/frontend/api/clients/failures/index.ts b/frontend/lib/api/clients/failures/index.ts similarity index 100% rename from frontend/api/clients/failures/index.ts rename to frontend/lib/api/clients/failures/index.ts diff --git a/frontend/api/clients/index.ts b/frontend/lib/api/clients/index.ts similarity index 100% rename from frontend/api/clients/index.ts rename to frontend/lib/api/clients/index.ts diff --git a/frontend/api/clients/runs/getRun.ts b/frontend/lib/api/clients/runs/getRun.ts similarity index 100% rename from frontend/api/clients/runs/getRun.ts rename to frontend/lib/api/clients/runs/getRun.ts diff --git a/frontend/api/clients/runs/getRuns.ts b/frontend/lib/api/clients/runs/getRuns.ts similarity index 100% rename from frontend/api/clients/runs/getRuns.ts rename to frontend/lib/api/clients/runs/getRuns.ts diff --git a/frontend/api/clients/runs/index.ts b/frontend/lib/api/clients/runs/index.ts similarity index 100% rename from frontend/api/clients/runs/index.ts rename to frontend/lib/api/clients/runs/index.ts diff --git a/frontend/api/clients/snapshots/getSnapshots.ts b/frontend/lib/api/clients/snapshots/getSnapshots.ts similarity index 100% rename from frontend/api/clients/snapshots/getSnapshots.ts rename to frontend/lib/api/clients/snapshots/getSnapshots.ts diff --git a/frontend/api/clients/snapshots/index.ts b/frontend/lib/api/clients/snapshots/index.ts similarity index 100% rename from frontend/api/clients/snapshots/index.ts rename to frontend/lib/api/clients/snapshots/index.ts diff --git a/frontend/api/clients/totals/getEpisodesCount.ts b/frontend/lib/api/clients/totals/getEpisodesCount.ts similarity index 100% rename from frontend/api/clients/totals/getEpisodesCount.ts rename to frontend/lib/api/clients/totals/getEpisodesCount.ts diff --git a/frontend/api/clients/totals/getRunsCount.ts b/frontend/lib/api/clients/totals/getRunsCount.ts similarity index 100% rename from frontend/api/clients/totals/getRunsCount.ts rename to frontend/lib/api/clients/totals/getRunsCount.ts diff --git a/frontend/api/clients/totals/index.ts b/frontend/lib/api/clients/totals/index.ts similarity index 100% rename from frontend/api/clients/totals/index.ts rename to frontend/lib/api/clients/totals/index.ts diff --git a/frontend/api/hooks/index.ts b/frontend/lib/api/hooks/index.ts similarity index 100% rename from frontend/api/hooks/index.ts rename to frontend/lib/api/hooks/index.ts diff --git a/frontend/api/hooks/queryKeys.ts b/frontend/lib/api/hooks/queryKeys.ts similarity index 100% rename from frontend/api/hooks/queryKeys.ts rename to frontend/lib/api/hooks/queryKeys.ts diff --git a/frontend/api/hooks/useEpisode.ts b/frontend/lib/api/hooks/useEpisode.ts similarity index 100% rename from frontend/api/hooks/useEpisode.ts rename to frontend/lib/api/hooks/useEpisode.ts diff --git a/frontend/api/hooks/useEpisodeDetail.ts b/frontend/lib/api/hooks/useEpisodeDetail.ts similarity index 100% rename from frontend/api/hooks/useEpisodeDetail.ts rename to frontend/lib/api/hooks/useEpisodeDetail.ts diff --git a/frontend/api/hooks/useEpisodeRealtime.ts b/frontend/lib/api/hooks/useEpisodeRealtime.ts similarity index 70% rename from frontend/api/hooks/useEpisodeRealtime.ts rename to frontend/lib/api/hooks/useEpisodeRealtime.ts index c3971b0..550dae1 100644 --- a/frontend/api/hooks/useEpisodeRealtime.ts +++ b/frontend/lib/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/api/hooks/useEpisodes.ts b/frontend/lib/api/hooks/useEpisodes.ts similarity index 100% rename from frontend/api/hooks/useEpisodes.ts rename to frontend/lib/api/hooks/useEpisodes.ts diff --git a/frontend/api/hooks/useEvents.ts b/frontend/lib/api/hooks/useEvents.ts similarity index 100% rename from frontend/api/hooks/useEvents.ts rename to frontend/lib/api/hooks/useEvents.ts diff --git a/frontend/api/hooks/useFailureBreakdown.ts b/frontend/lib/api/hooks/useFailureBreakdown.ts similarity index 100% rename from frontend/api/hooks/useFailureBreakdown.ts rename to frontend/lib/api/hooks/useFailureBreakdown.ts diff --git a/frontend/api/hooks/usePalletStates.ts b/frontend/lib/api/hooks/usePalletStates.ts similarity index 100% rename from frontend/api/hooks/usePalletStates.ts rename to frontend/lib/api/hooks/usePalletStates.ts diff --git a/frontend/api/hooks/usePlacements.ts b/frontend/lib/api/hooks/usePlacements.ts similarity index 100% rename from frontend/api/hooks/usePlacements.ts rename to frontend/lib/api/hooks/usePlacements.ts diff --git a/frontend/api/hooks/useRun.ts b/frontend/lib/api/hooks/useRun.ts similarity index 100% rename from frontend/api/hooks/useRun.ts rename to frontend/lib/api/hooks/useRun.ts diff --git a/frontend/api/hooks/useRunningEpisode.ts b/frontend/lib/api/hooks/useRunningEpisode.ts similarity index 100% rename from frontend/api/hooks/useRunningEpisode.ts rename to frontend/lib/api/hooks/useRunningEpisode.ts diff --git a/frontend/api/hooks/useRunningEpisodeRealtime.ts b/frontend/lib/api/hooks/useRunningEpisodeRealtime.ts similarity index 100% rename from frontend/api/hooks/useRunningEpisodeRealtime.ts rename to frontend/lib/api/hooks/useRunningEpisodeRealtime.ts diff --git a/frontend/api/hooks/useRuns.ts b/frontend/lib/api/hooks/useRuns.ts similarity index 100% rename from frontend/api/hooks/useRuns.ts rename to frontend/lib/api/hooks/useRuns.ts diff --git a/frontend/api/hooks/useRunsTotals.ts b/frontend/lib/api/hooks/useRunsTotals.ts similarity index 100% rename from frontend/api/hooks/useRunsTotals.ts rename to frontend/lib/api/hooks/useRunsTotals.ts diff --git a/frontend/api/hooks/useSnapshots.ts b/frontend/lib/api/hooks/useSnapshots.ts similarity index 100% rename from frontend/api/hooks/useSnapshots.ts rename to frontend/lib/api/hooks/useSnapshots.ts diff --git a/frontend/api/types.ts b/frontend/lib/api/types.ts similarity index 100% rename from frontend/api/types.ts rename to frontend/lib/api/types.ts diff --git a/frontend/lib/pallet.ts b/frontend/lib/pallet.ts index 3394fe0..ac086cc 100644 --- a/frontend/lib/pallet.ts +++ b/frontend/lib/pallet.ts @@ -56,14 +56,42 @@ export function lastPlacement(placements: Placement[]): Placement | null { export type Box = { x0: number; x1: number; y0: number; y1: number }; -/** Caja envolvente, en planta, de un grupo de colocaciones. `null` si no hay ninguna. */ +/** Las cuatro esquinas en planta, en metros, ya giradas por el `yaw` de la pose. + * + * El planificador gira paquetes a propósito —90° para que entren en la fila—, así que + * `dims_m` NO se puede leer como si siempre estuviera alineado con los ejes: una caja + * de 480x340 puesta de canto ocupa 340 de ancho, no 480. */ +export function corners(p: Drawable): Array<[number, number]> { + const { x, y, yaw } = p.actual_pose; + const [dx, dy] = p.dims_m; + const cos = Math.cos(yaw ?? 0); + const sin = Math.sin(yaw ?? 0); + const esquinas: ReadonlyArray = [ + [-dx / 2, -dy / 2], [dx / 2, -dy / 2], [dx / 2, dy / 2], [-dx / 2, dy / 2], + ]; + return esquinas.map(([u, v]) => [x + u * cos - v * sin, y + u * sin + v * cos]); +} + +/** Lo que la caja ocupa a lo largo de un eje una vez girada. Es lo que ve el alzado: un + * paquete de canto se dibuja estrecho aunque su lado largo siga siendo el mismo. */ +export function span(dx: number, dy: number, yaw: number | undefined): number { + return dx * Math.abs(Math.cos(yaw ?? 0)) + dy * Math.abs(Math.sin(yaw ?? 0)); +} + +/** Caja envolvente, en planta, de un grupo de colocaciones. `null` si no hay ninguna. + * + * Es la envolvente de las esquinas GIRADAS. Con giros que no son múltiplos de 90° el + * apoyo real es el casco convexo de esas esquinas y este rectángulo lo sobreestima; se + * usa para encuadrar el dibujo, nunca para decidir si el montón aguanta —ese número lo + * calcula el backend (`theker_telemetry.pallet.stability_margin`) y llega ya hecho. */ export function envelope(placements: Drawable[]): Box | null { if (!placements.length) return null; + const pts = placements.flatMap(corners); return { - x0: Math.min(...placements.map((p) => p.actual_pose.x - p.dims_m[0] / 2)), - x1: Math.max(...placements.map((p) => p.actual_pose.x + p.dims_m[0] / 2)), - y0: Math.min(...placements.map((p) => p.actual_pose.y - p.dims_m[1] / 2)), - y1: Math.max(...placements.map((p) => p.actual_pose.y + p.dims_m[1] / 2)), + x0: Math.min(...pts.map(([x]) => x)), + x1: Math.max(...pts.map(([x]) => x)), + y0: Math.min(...pts.map(([, y]) => y)), + y1: Math.max(...pts.map(([, y]) => y)), }; } 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/next.config.ts b/frontend/next.config.ts index 5d30399..fa38560 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -24,12 +24,23 @@ const PUBLICAS: Record = { /** Nunca, bajo ningún nombre, puede acabar esto en el navegador. */ const PROHIBIDAS = ["SERVICE_KEY", "SERVICE_ROLE", "SECRET", "PASSWORD"]; +function mapearPublicas(origen: string, valor: string | undefined): void { + const publica = PUBLICAS[origen]; + if (!publica || !valor || process.env[publica]) return; + process.env[publica] = valor; +} + function cargarEnvDeLaRaiz(): void { + // Vercel y CI inyectan las claves en el entorno, no en un `.env` de disco. + for (const origen of Object.keys(PUBLICAS)) { + mapearPublicas(origen, process.env[origen]); + } + let texto: string; try { texto = readFileSync(resolve(process.cwd(), "..", ".env"), "utf8"); } catch { - return; // Sin .env no pasa nada: puede venir del entorno, como en CI. + return; } for (const linea of texto.split("\n")) { @@ -40,12 +51,9 @@ function cargarEnvDeLaRaiz(): void { if (corte < 0) continue; const clave = limpia.slice(0, corte).trim(); - const publica = PUBLICAS[clave]; - if (!publica) continue; - const valor = limpia.slice(corte + 1).trim().replace(/^['"]|['"]$/g, ""); // Lo ya exportado gana, para que CI y un `export` puntual manden sobre el fichero. - if (valor && !process.env[publica]) process.env[publica] = valor; + mapearPublicas(clave, valor); } } 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);