Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b4e7a1e
feat(front): añade las piezas base del resumen de evolución
AnderVarela Sep 19, 2026
581b61c
feat(front): añade la tarjeta de nivel y la sección de tarea
AnderVarela Sep 19, 2026
bf941bc
fix(front): la serie de un nivel usa el rango de semillas más frecuente
AnderVarela Sep 19, 2026
e7a1a48
feat(front): el resumen de evolución es la pantalla principal
AnderVarela Sep 19, 2026
296371c
style(front): el resumen ocupa todo el ancho, con lista lateral fija
AnderVarela Sep 19, 2026
34106a8
Merge remote-tracking branch 'upstream-dev' into feature/front-dashbo…
AnderVarela Sep 19, 2026
53d1d8b
style(front): más aire entre las tarjetas del resumen
AnderVarela Sep 19, 2026
665a1ed
fix(front): Live distingue la conexión perdida del episodio que no en…
AnderVarela Sep 19, 2026
e18e18f
fix(front): los rellenos del dibujo del palé son opacos y la leyenda …
AnderVarela Sep 19, 2026
18547f5
feat(front): selector Esquema/Captura y capturas enmarcadas en el panel
AnderVarela Sep 19, 2026
8c02e66
style(front): la captura es una tarjeta con la proporción de la foto,…
AnderVarela Sep 19, 2026
4939e10
fix(front): en el resumen la flecha dice si va a mejor o a peor
AnderVarela Sep 19, 2026
9f14978
feat(front): iconos de ayuda en el resumen que explican cada cosa
AnderVarela Sep 19, 2026
d8ad4d1
refactor(front): quita los iconos de ayuda de la cabecera de la tarje…
AnderVarela Sep 19, 2026
39f38b2
fix(front): la comparativa no falla al comparar dos ejecuciones del m…
AnderVarela Sep 19, 2026
6caae41
Merge remote-tracking branch 'origin/dev' into feature/front-dashboar…
AnderVarela Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ backend/ SQL + SDK. Supabase ES el backend; no hay servidor prop
pyproject.toml

frontend/ Next.js 16. Lee Supabase con la clave anon, bajo RLS.
app/ rutas: / (Live), /runs, /runs/[id], /runs/[id]/[seed]
app/ rutas: / (Resumen), /live, /runs, /runs/[id], /runs/[id]/[seed]
api/clients/ una función por endpoint de PostgREST
api/hooks/ los mismos, envueltos en TanStack Query
components/ atoms / organisms / screens
Expand Down Expand Up @@ -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
Expand Down
24 changes: 18 additions & 6 deletions frontend/api/hooks/useEpisodeRealtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,21 @@ function append<T extends { id: string }>(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<number | null>(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}`)
Expand Down Expand Up @@ -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 };
}
5 changes: 5 additions & 0 deletions frontend/app/live/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { LiveScreen } from "@/components/screens/LiveScreen";

export default function Page() {
return <LiveScreen />;
}
4 changes: 2 additions & 2 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LiveScreen } from "@/components/screens/LiveScreen";
import { DashboardScreen } from "@/components/screens/DashboardScreen";

export default function Page() {
return <LiveScreen />;
return <DashboardScreen />;
}
1 change: 1 addition & 0 deletions frontend/components/atoms/Badge/Badge.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/atoms/Badge/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span className={cx(styles.badge, styles[tone])}>{children}</span>;
}
5 changes: 4 additions & 1 deletion frontend/components/atoms/Icon/Icon.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -18,6 +18,9 @@ const PATHS: Record<IconName, ReactNode> = {
chevronLeft: <path d="M10 3.5 5.5 8l4.5 4.5" />,
chevronRight: <path d="M6 3.5 10.5 8 6 12.5" />,
arrowLeft: <path d="M13 8H3M7 4 3 8l4 4" />,
arrowUp: <path d="M8 13.5v-10M4 7.5l4-4 4 4" />,
minus: <path d="M3.5 8h9" />,
info: (<><circle cx="8" cy="8" r="6" /><path d="M8 7.2v3.6" /><circle cx="8" cy="4.9" r="0.55" fill="currentColor" /></>),
};

/** Decorativo: el significado lo lleva el texto de al lado, no el icono. */
Expand Down
54 changes: 54 additions & 0 deletions frontend/components/atoms/Trend/Trend.helper.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
19 changes: 19 additions & 0 deletions frontend/components/atoms/Trend/Trend.module.css
Original file line number Diff line number Diff line change
@@ -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; }
35 changes: 35 additions & 0 deletions frontend/components/atoms/Trend/Trend.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg className={cx(styles.trend, styles[tone])} viewBox={`0 0 ${g.width} ${g.height}`}
role="img" aria-label={label}>
{g.baselineY != null && (
<line className={styles.baseline} x1={0} x2={g.width} y1={g.baselineY} y2={g.baselineY} />
)}
<path className={cx(styles.line, dashed && styles.dashed)} d={g.path} />
{g.dots.map((d, i) => (
<circle key={i} className={d.last ? styles.last : styles.dot}
cx={d.x} cy={d.y} r={d.last ? 4.5 : 3}>
<title>{d.title}</title>
</circle>
))}
</svg>
);
}
2 changes: 2 additions & 0 deletions frontend/components/atoms/Trend/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Trend } from "./Trend";
export type { TrendPoint } from "./Trend.helper";
2 changes: 2 additions & 0 deletions frontend/components/atoms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
@@ -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; }
22 changes: 22 additions & 0 deletions frontend/components/molecules/ChangeLegend/ChangeLegend.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.legend}>
<Text variant="body" size="lg" className={styles.title}>Cómo leerlo</Text>
<div className={styles.pills}>
<ChangePill direction="up" tone="ok">mejora</ChangePill>
<ChangePill direction="down" tone="bad">empeora</ChangePill>
<ChangePill direction="flat" tone="muted">igual</ChangePill>
</div>
<Text variant="caption" tone="muted" className={styles.text}>
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).
</Text>
</div>
);
}
1 change: 1 addition & 0 deletions frontend/components/molecules/ChangeLegend/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ChangeLegend } from "./ChangeLegend";
18 changes: 18 additions & 0 deletions frontend/components/molecules/ChangePill/ChangePill.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Badge tone={tone === "muted" ? "neutral" : tone}>
<Icon name={ICON[direction]} size={12} />{children}
</Badge>
);
}
1 change: 1 addition & 0 deletions frontend/components/molecules/ChangePill/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ChangePill } from "./ChangePill";
19 changes: 19 additions & 0 deletions frontend/components/molecules/InfoTip/InfoTip.helper.ts
Original file line number Diff line number Diff line change
@@ -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" };
}
35 changes: 35 additions & 0 deletions frontend/components/molecules/InfoTip/InfoTip.module.css
Original file line number Diff line number Diff line change
@@ -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; }
Loading
Loading