diff --git a/AGENTS.md b/AGENTS.md index dcdbf04..75bfa4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,9 +214,13 @@ medir algo nuevo no obliga a migrar el esquema a las tres de la mañana. Vistas: `v_run_summary`, `v_episode_summary`, `v_failure_breakdown`. Realtime está publicado en `events`, `pallet_states` y `episodes`; Live se alimenta de ahí. -`task` es un campo (`induction` | `palletizing`), no una rama del código: así la línea -base de inducción sigue siendo comparable cuando entre el paletizado, y el jurado ve -evolución en vez de borrón y cuenta nueva. +`task` es un campo, no una rama del código: así la línea base sigue siendo comparable +cuando entra una tarea nueva, y el jurado ve evolución en vez de borrón y cuenta nueva. +En el paletizado real la tarea es **de dónde se coge** el paquete —`table` | `conveyor` +| `truck`, el mismo identificador que `runs.config.source`—, que es lo que permite +comparar mesa, cinta y camión entre sí. `induction`, `palletizing` y +`paletizado-guionizado` se quedan en el CHECK como histórico: dice qué se puede +ALMACENAR, no qué se produce hoy. --- diff --git a/backend/sql/001_schema.sql b/backend/sql/001_schema.sql index 3171a6c..cff6a52 100644 --- a/backend/sql/001_schema.sql +++ b/backend/sql/001_schema.sql @@ -93,7 +93,11 @@ begin loop execute format('alter table %I drop constraint if exists %I', t, t || '_task_check'); execute format( - 'alter table %I add constraint %I check (task in (''induction'', ''palletizing'', ''paletizado-guionizado''))', + 'alter table %I add constraint %I check (task in (' + -- Vigentes: la fuente de la que se coge el paquete, igual que config.source. + '''table'', ''conveyor'', ''truck'', ' + -- Histórico: lo que ya está subido. Este CHECK dice qué se puede ALMACENAR. + '''induction'', ''palletizing'', ''paletizado-guionizado''))', t, t || '_task_check'); end loop; end $$; diff --git a/backend/theker_telemetry/schema.py b/backend/theker_telemetry/schema.py index fc01f98..44426f6 100644 --- a/backend/theker_telemetry/schema.py +++ b/backend/theker_telemetry/schema.py @@ -34,7 +34,15 @@ # Tareas que el repo sabe medir. La tarea es un campo, no una rama del código: así la # línea base de inducción sigue siendo comparable cuando entre el paletizado. -TASKS = frozenset({"induction", "palletizing", "paletizado-guionizado"}) +# +# En el paletizado real la tarea es DE DÓNDE SE COGE el paquete —mesa, cinta o camión, +# con el mismo identificador que `runs.config.source`—, y no una etiqueta única para los +# nueve niveles: son tres tareas distintas y compararlas entre sí es justo lo que se le +# pide a la plataforma. Los otros tres son histórico y siguen siendo almacenables. +TASKS = frozenset({ + "table", "conveyor", "truck", + "induction", "palletizing", "paletizado-guionizado", +}) @dataclass diff --git a/frontend/components/atoms/Donut/Donut.helper.ts b/frontend/components/atoms/Donut/Donut.helper.ts new file mode 100644 index 0000000..bcb63a2 --- /dev/null +++ b/frontend/components/atoms/Donut/Donut.helper.ts @@ -0,0 +1,47 @@ +export type DonutSegment = { + key: string; + label: string; + value: number; + /** El color viene de fuera: es un dato de identidad, no una decisión del gráfico. */ + color: string; +}; + +export type DonutArc = { + key: string; + color: string; + /** `stroke-dasharray`: lo que pinta el trazo y lo que deja en blanco. */ + dash: string; + offset: number; + title: string; +}; + +export const R = 40; +export const C = 2 * Math.PI * R; + +/** + * De cantidades a arcos del anillo. Cada segmento ocupa su parte del total, y el trazo se + * dibuja con `stroke-dasharray` sobre una sola circunferencia: no hace falta calcular + * ningún path. + * + * Los segmentos a cero no se dibujan, pero el que vale el total entero sí ocupa la vuelta + * completa: «todos los episodios fallaron» es un anillo entero de su color, no un hueco. + */ +export function donutArcs(segments: DonutSegment[]): { arcs: DonutArc[]; total: number } { + const total = segments.reduce((a, s) => a + Math.max(0, s.value), 0); + if (!total) return { arcs: [], total: 0 }; + + let acc = 0; + const arcs = segments.filter((s) => s.value > 0).map((s) => { + const len = (s.value / total) * C; + const arc: DonutArc = { + key: s.key, + color: s.color, + dash: `${len.toFixed(3)} ${(C - len).toFixed(3)}`, + offset: -acc, + title: `${s.label}: ${s.value} de ${total}`, + }; + acc += len; + return arc; + }); + return { arcs, total }; +} diff --git a/frontend/components/atoms/Donut/Donut.module.css b/frontend/components/atoms/Donut/Donut.module.css new file mode 100644 index 0000000..aa6d55f --- /dev/null +++ b/frontend/components/atoms/Donut/Donut.module.css @@ -0,0 +1,28 @@ +.donut { display: block; width: 100%; max-width: 172px; height: auto; } + +.track { fill: none; stroke: var(--row-alt); stroke-width: 15; } + +.arc { + fill: none; + stroke-width: 15; + /* Sin unión visible entre tramos: los separa el color, no un hueco. */ + stroke-linecap: butt; +} + +/* Monoespaciada, como toda cifra de la interfaz. */ +.value { + fill: var(--text); + font-family: var(--font-mono), ui-monospace, monospace; + font-size: 22px; + font-weight: 500; + text-anchor: middle; + dominant-baseline: middle; +} + +.label { + fill: var(--text-4); + font-family: var(--font-sans), system-ui, sans-serif; + font-size: 8px; + text-anchor: middle; + dominant-baseline: middle; +} diff --git a/frontend/components/atoms/Donut/Donut.tsx b/frontend/components/atoms/Donut/Donut.tsx new file mode 100644 index 0000000..6f9e080 --- /dev/null +++ b/frontend/components/atoms/Donut/Donut.tsx @@ -0,0 +1,39 @@ +import { C, R, donutArcs, type DonutSegment } from "./Donut.helper"; +import styles from "./Donut.module.css"; + +type Props = { + segments: DonutSegment[]; + /** El número grande del centro, ya formateado. */ + centerValue: string; + /** Qué es ese número: «episodios». */ + centerLabel: string; + /** Lo que dice el gráfico, para quien no lo ve. */ + label: string; + /** Ancho máximo en px. El alto va solo: el anillo es cuadrado. */ + size?: number; +}; + +/** Un anillo con el reparto de un total. Sin etiquetas dentro: los nombres y las cantidades + * van en la leyenda de al lado, y cada tramo enseña el suyo al pasar el ratón. */ +export function Donut({ segments, centerValue, centerLabel, label, size = 172 }: Props) { + const { arcs, total } = donutArcs(segments); + if (!total) return null; + + return ( + + {/* El anillo arranca arriba y no a las tres en punto, que es como se lee un reparto. */} + + + {arcs.map((a) => ( + + {a.title} + + ))} + + {centerValue} + {centerLabel} + + ); +} diff --git a/frontend/components/atoms/Donut/index.ts b/frontend/components/atoms/Donut/index.ts new file mode 100644 index 0000000..61f3944 --- /dev/null +++ b/frontend/components/atoms/Donut/index.ts @@ -0,0 +1,2 @@ +export { Donut } from "./Donut"; +export type { DonutSegment } from "./Donut.helper"; diff --git a/frontend/components/atoms/Trend/Trend.helper.ts b/frontend/components/atoms/Trend/Trend.helper.ts index 4adeb37..b9ce7d3 100644 --- a/frontend/components/atoms/Trend/Trend.helper.ts +++ b/frontend/components/atoms/Trend/Trend.helper.ts @@ -11,6 +11,7 @@ export type TrendGeometry = { }; const W = 400; +/** El alto de siempre. Quien quiera la curva más grande lo pasa. */ const H = 64; const PAD_X = 10; const PAD_Y = 10; @@ -20,16 +21,18 @@ 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 { +export function trendGeometry( + points: TrendPoint[], height: number = H, width: number = W, +): 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); + points.length === 1 ? width / 2 : PAD_X + (i / (points.length - 1)) * (width - PAD_X * 2); const y = (v: number) => - span === 0 ? H / 2 : PAD_Y + ((hi - v) / span) * (H - PAD_Y * 2); + span === 0 ? height / 2 : PAD_Y + ((hi - v) / span) * (height - PAD_Y * 2); let path = ""; let pen = false; @@ -48,7 +51,7 @@ export function trendGeometry(points: TrendPoint[]): TrendGeometry { const first = values[0]; return { - width: W, height: H, path: path.trim(), dots, + width, height, 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 index f08cb0a..e4d4505 100644 --- a/frontend/components/atoms/Trend/Trend.module.css +++ b/frontend/components/atoms/Trend/Trend.module.css @@ -1,4 +1,6 @@ .trend { width: 100%; height: 64px; display: block; overflow: visible; } +/* El alto lo pone la proporción del viewBox, así el dibujo llena el ancho de su caja. */ +.fluid { height: auto; } .ok { --tone: var(--ok); } .bad { --tone: var(--bad); } diff --git a/frontend/components/atoms/Trend/Trend.tsx b/frontend/components/atoms/Trend/Trend.tsx index ef92678..27c8b5c 100644 --- a/frontend/components/atoms/Trend/Trend.tsx +++ b/frontend/components/atoms/Trend/Trend.tsx @@ -10,16 +10,23 @@ type Props = { dashed?: boolean; /** Lo que dice el gráfico, para quien no lo ve: «Tasa de éxito, de 41 % a 64 %». */ label: string; + /** Alto de referencia en px. Al pasarlo, la curva llena el ancho disponible y su alto + * sale de la proporción; sin él, el alto fijo de las tarjetas. */ + height?: number; }; +/** Cuánto más ancha que alta se dibuja la curva fluida. Ocho a uno: sitio de sobra para + * ver la forma sin que una ejecución más la aplaste. */ +const RATIO = 8; + /** 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); +export function Trend({ points, tone, dashed, label, height }: Props) { + const g = trendGeometry(points, height, height == null ? undefined : height * RATIO); return ( - + {g.baselineY != null && ( )} diff --git a/frontend/components/atoms/index.ts b/frontend/components/atoms/index.ts index 9605734..c824439 100644 --- a/frontend/components/atoms/index.ts +++ b/frontend/components/atoms/index.ts @@ -20,6 +20,8 @@ export type { SelectOption } from "./Select"; export { Swatch } from "./Swatch"; export { Table, Th, Td, Tr } from "./Table"; export { Sparkline } from "./Sparkline"; +export { Donut } from "./Donut"; +export type { DonutSegment } from "./Donut"; export { Logo } from "./Logo"; export { IconButton } from "./IconButton"; export { Trend } from "./Trend"; diff --git a/frontend/components/organisms/ProgressPanel/ProgressPanel.helper.ts b/frontend/components/organisms/ProgressPanel/ProgressPanel.helper.ts new file mode 100644 index 0000000..525f1ea --- /dev/null +++ b/frontend/components/organisms/ProgressPanel/ProgressPanel.helper.ts @@ -0,0 +1,173 @@ +import type { Run } from "@/lib/supabase"; +import { + TASK_TEXT, capitalize, changeTone, failureColor, failureText, pct, shortDate, signedNumber, +} from "@/lib/ui"; +import type { DonutSegment, TrendPoint } from "../../atoms"; + +export type OverallStat = { key: string; label: string; value: string; unit: string }; + +export type OverallView = { + task: string; + /** «Paletizado». */ + title: string; + /** «48 ejecuciones · 19 sept – 20 sept». */ + meta: string; + stats: OverallStat[]; + /** Un punto por ejecución. Vacío si no hay dos que comparar. */ + points: TrendPoint[]; + tone: "ok" | "bad" | "muted"; + chartLabel: string; + /** El avance acumulado, ya formateado: «+23 puntos». `null` sin curva. */ + headline: string | null; + /** En qué acabaron los episodios: el reparto del anillo. */ + outcomes: DonutSegment[]; + outcomeTotal: number; + outcomeLabel: string; +}; + +const byDateAsc = (a: Run, b: Run) => Date.parse(a.started_at) - Date.parse(b.started_at); + +function sum(runs: Run[], pick: (r: Run) => number | null): number | null { + const known = runs.map(pick).filter((v): v is number => v != null); + return known.length ? known.reduce((a, b) => a + b, 0) : null; +} + +function ratio(top: number | null, bottom: number | null): number | null { + return top == null || !bottom ? null : top / bottom; +} + +/** + * La mirada de lejos: lo que se ha medido en total y cuánto se ha avanzado, sin entrar en + * niveles. `runs` ya viene filtrado a UNA clase de datos. + * + * Solo la PRIMERA de `tasks`, que es la tarea en la que se trabaja ahora: ese orden lo pone + * la pantalla y es el mismo de la lista de tarjetas. Una mirada de lejos con varias tareas + * apiladas deja de serlo, y las demás siguen contándose en sus propias tarjetas, que es + * donde se miran. + * + * Los cuatro números son sumas, no medianas, y por eso se pueden hacer aquí sin mentir: la + * tasa de éxito es éxitos/episodios sobre el total, no el promedio de las tasas de cada + * ejecución, así que una prueba suelta de 3 episodios no pesa lo mismo que un benchmark de + * 40. Una mediana global (ciclo, estabilidad, error) NO se puede sacar de aquí —la mediana + * de las medianas de cada ejecución no es la mediana— y por eso no se enseña ninguna: haría + * falta una vista que agregue sobre episodios. + */ +export function buildOverall(runs: Run[], tasks: string[]): OverallView[] { + return tasks.slice(0, 1).map((task) => { + const sorted = runs.filter((r) => r.task === task).sort(byDateAsc); + if (!sorted.length) return null; + const episodes = sum(sorted, (r) => r.episodes); + const from = shortDate(sorted[0].started_at); + const to = shortDate(sorted[sorted.length - 1].started_at); + + return { + task, + title: capitalize(TASK_TEXT[task] ?? task), + meta: `${sorted.length} ${sorted.length === 1 ? "ejecución" : "ejecuciones"} · ` + + (from === to ? from : `${from} – ${to}`), + stats: [ + { key: "runs", label: "Ejecuciones", value: `${sorted.length}`, unit: "" }, + { key: "episodes", label: "Episodios", value: episodes == null ? "—" : `${episodes}`, unit: "" }, + quantity("success", "Tasa de éxito", pct(ratio(sum(sorted, (r) => r.successes), episodes))), + quantity("placement", "Paquetes colocados", + pct(ratio(sum(sorted, (r) => r.objects_placed), sum(sorted, (r) => r.objects_total)))), + ], + ...progressCurve(sorted), + ...outcomes(sorted), + }; + }).filter((v): v is OverallView => v != null); +} + +/** + * En qué acabaron los episodios, en tres tramos: los que salieron bien, los que se fueron + * al suelo y los que fallaron por cualquier otro motivo. + * + * El derrumbe va aparte porque no es un fallo más: es EL fallo de apilar, el que mide si el + * planificador entiende el centro de gravedad. Juntarlo con los demás esconde justo lo que + * esta plataforma existe para vigilar. + * + * Se cuenta sobre `seed_series`, que ya viene en cada fila con un episodio por semilla: no + * hace falta pedir nada. Los que siguen corriendo no entran: todavía no han acabado en nada. + */ +function outcomes(runs: Run[]): Pick { + const episodes = runs.flatMap((r) => r.seed_series ?? []); + let ok = 0; + let collapse = 0; + let other = 0; + for (const e of episodes) { + if (e.status === "running") continue; + if (e.status === "success") ok += 1; + else if (e.failure === "stack_collapse") collapse += 1; + else other += 1; + } + + const segments: DonutSegment[] = [ + { key: "ok", label: "Funcionaron", value: ok, color: "var(--ok)" }, + { + key: "collapse", + label: capitalize(failureText("stack_collapse")), + value: collapse, + color: failureColor("stack_collapse"), + }, + { key: "other", label: "Fallaron por otro motivo", value: other, color: "var(--bad)" }, + ]; + + const total = ok + collapse + other; + return { + outcomes: segments, + outcomeTotal: total, + outcomeLabel: `De ${total} episodios acabados, ${ok} funcionaron, en ${collapse} se ` + + `derrumbó el montón y ${other} fallaron por otro motivo`, + }; +} + +/** Parte «44 %» en número y unidad, para pintarlas a tamaños distintos. */ +function quantity(key: string, label: string, formatted: string): OverallStat { + const [value, ...rest] = formatted.split(/\s/); + return { key, label, value, unit: rest.join(" ") }; +} + +/** + * La curva de avance: un punto por ejecución, en orden, y el valor es cuántos puntos de tasa + * de éxito lleva ESA ejecución respecto a la PRIMERA DE SU NIVEL. + * + * Por qué así y no la tasa de éxito a secas. Los niveles no miden lo mismo, y una línea con + * la tasa cruda baja en cuanto entra un nivel más difícil: leería «va a peor» justo cuando el + * sistema ha avanzado. Midiendo cada ejecución contra el arranque de su propio nivel, la + * línea sube cuando se mejora y baja cuando se empeora, entre el nivel que entre, y no se + * compara nunca un nivel con otro. + */ +function progressCurve(sorted: Run[]): Pick { + const base = new Map(); + for (const r of sorted) { + if (r.success_rate != null && !base.has(r.level)) base.set(r.level, r.success_rate); + } + + const points: TrendPoint[] = sorted.map((r) => { + const start = base.get(r.level); + const value = r.success_rate == null || start == null ? null : (r.success_rate - start) * 100; + return { + value, + title: `${r.git_sha || r.label || "sin commit"} · ${shortDate(r.started_at)} · ` + + `nivel ${r.level} · ${pct(r.success_rate)} · ` + + (value == null ? "sin dato" : `${signedNumber(value, 0)} puntos desde su arranque`), + }; + }); + + const known = points.map((p) => p.value).filter((v): v is number => v != null); + // Sin dos puntos no hay curva. Y si NINGUNO se ha movido del arranque, tampoco: una línea + // plana de punta a punta ocupa lo que una curva y solo dice «todavía no ha pasado nada», + // que es justo lo que ya dice el «0 puntos» de al lado. + if (known.length < 2 || known.every((v) => Math.round(v) === 0)) { + return { points: [], tone: "muted", chartLabel: "", headline: null }; + } + + const last = known[known.length - 1]; + return { + points, + tone: changeTone(last, 0, false), + chartLabel: `Avance de la tasa de éxito: ${signedNumber(last, 0)} puntos en la última de ` + + `${points.length} ejecuciones`, + headline: `${signedNumber(last, 0)} puntos`, + }; +} diff --git a/frontend/components/organisms/ProgressPanel/ProgressPanel.module.css b/frontend/components/organisms/ProgressPanel/ProgressPanel.module.css new file mode 100644 index 0000000..507e72e --- /dev/null +++ b/frontend/components/organisms/ProgressPanel/ProgressPanel.module.css @@ -0,0 +1,43 @@ +/* Mismo aire que las demás tarjetas del Resumen: el panel no estrena un lenguaje propio. + El ancho lo mide el PANEL y no la ventana, porque vive en una columna que cambia de + tamaño con el diseño de la pantalla (igual que LevelCard). */ +.panel { padding: 20px 24px 22px; gap: 18px; container-type: inline-size; } + +.head { display: flex; align-items: center; justify-content: space-between; gap: 10px 16px; flex-wrap: wrap; } +.heading { display: flex; align-items: center; gap: 4px; } +.title { font-weight: 700; } + +.block { display: flex; flex-direction: column; gap: 16px; min-width: 0; } +.blockHead { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 14px; } + +/* A la izquierda las cifras y, debajo, la curva; a la derecha SOLO el anillo. El anillo no + crece: su tamaño lo fija el propio dibujo, así que la curva llena lo que queda y acaba + justo donde el anillo empieza. Por debajo del ancho de corte, uno encima del otro. */ +.body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px 32px; } +@container (min-width: 620px) { + .body { grid-template-columns: minmax(0, 1fr) auto; align-items: start; } +} + +.left { display: flex; flex-direction: column; gap: 20px; min-width: 0; } + +/* Dos por fila en cuanto haya sitio: cuatro cifras en línea obligan a estrecharlas. */ +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 16px 20px; + align-content: start; +} +.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; } + +/* El anillo manda en su columna y la leyenda va debajo, no al lado: así el dibujo no pierde + ancho por los nombres de las causas. */ +.outcomes { display: flex; flex-direction: column; align-items: center; gap: 10px; min-width: 0; } +.legend { display: flex; flex-direction: column; gap: 6px; min-width: 0; align-self: stretch; } + +/* La curva, dentro de la columna izquierda y a todo el ancho de esa columna. */ +.curve { display: flex; flex-direction: column; gap: 10px; min-width: 0; } +.chartHead { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; } + +@media (max-width: 860px) { + .panel { padding: 18px; } +} diff --git a/frontend/components/organisms/ProgressPanel/ProgressPanel.tsx b/frontend/components/organisms/ProgressPanel/ProgressPanel.tsx new file mode 100644 index 0000000..f56ef84 --- /dev/null +++ b/frontend/components/organisms/ProgressPanel/ProgressPanel.tsx @@ -0,0 +1,103 @@ +import type { Run } from "@/lib/supabase"; +import { Badge, Card, Donut, Text, Trend, Value } from "../../atoms"; +import { ChangePill, ColorLegendItem, InfoTip } from "../../molecules"; +import { KIND_TEXT, type DataKind } from "../LevelCard"; +import { buildOverall } from "./ProgressPanel.helper"; +import styles from "./ProgressPanel.module.css"; + +const DIRECTION = { ok: "up", bad: "down", muted: "flat" } as const; + +/** Cómo va el sistema en conjunto, encima de la lista de niveles: cuánto se ha medido, cómo + * va y cuánto se ha avanzado. Una tarea por bloque, y nunca dos juntas: inducción y + * paletizado tienen criterios de éxito distintos y un número que los sume no dice nada. + * + * Aquí no se entra en niveles a propósito: eso ya lo cuentan las tarjetas de abajo. */ +export function ProgressPanel({ runs, tasks, kind }: { + runs: Run[]; + /** En qué orden van las tareas. El mismo que la lista de tarjetas de abajo. */ + tasks: string[]; + kind: DataKind; +}) { + const views = buildOverall(runs, tasks); + if (!views.length) return null; + const hatched = kind !== "measured"; + + return ( + +
+
+ Mejora general + +

+ Lo medido de esta tarea en total y cuánto se ha avanzado desde el principio, sin + entrar en niveles. +

+

+ La tasa de éxito es episodios con éxito entre episodios totales, no el promedio + de las tasas de cada ejecución: una prueba suelta de tres episodios no pesa lo + mismo que un benchmark de cuarenta. +

+
+
+ {hatched && {KIND_TEXT[kind]}} +
+ + {views.map((v) => ( +
+
+ + {views.length > 1 ? `En conjunto · ${v.title}` : "En conjunto"} + + {v.meta} +
+ +
+
+
+ {v.stats.map((s) => ( +
+ + {s.label} +
+ ))} +
+ + {/* Debajo de las cifras y sin pasar de su columna: acaba donde empieza el + anillo, que es el que manda a la derecha. */} + {v.points.length > 0 && ( +
+
+ Avance de la tasa de éxito + {v.headline && ( + + {v.headline} + + )} +
+ +
+ )} +
+ + {v.outcomeTotal > 0 && ( +
+ En qué acabaron + {/* «acabados» y no «episodios»: los que siguen en marcha no entran en el + reparto, y sin decirlo este número contradice al de arriba. */} + +
+ {v.outcomes.filter((s) => s.value > 0).map((s) => ( + + ))} +
+
+ )} +
+
+ ))} + + ); +} diff --git a/frontend/components/organisms/ProgressPanel/index.ts b/frontend/components/organisms/ProgressPanel/index.ts new file mode 100644 index 0000000..a23c1cd --- /dev/null +++ b/frontend/components/organisms/ProgressPanel/index.ts @@ -0,0 +1,3 @@ +export { ProgressPanel } from "./ProgressPanel"; +export { buildOverall } from "./ProgressPanel.helper"; +export type { OverallStat, OverallView } from "./ProgressPanel.helper"; diff --git a/frontend/components/organisms/index.ts b/frontend/components/organisms/index.ts index d007bcd..3371b9e 100644 --- a/frontend/components/organisms/index.ts +++ b/frontend/components/organisms/index.ts @@ -22,3 +22,4 @@ export type { DataKind, LevelCardView } from "./LevelCard"; export { TaskSection, anchorOf } from "./TaskSection"; export type { TaskSectionView } from "./TaskSection"; export { OverviewPanel } from "./OverviewPanel"; +export { ProgressPanel } from "./ProgressPanel"; diff --git a/frontend/components/screens/DashboardScreen/DashboardScreen.tsx b/frontend/components/screens/DashboardScreen/DashboardScreen.tsx index f4245f2..4195af0 100644 --- a/frontend/components/screens/DashboardScreen/DashboardScreen.tsx +++ b/frontend/components/screens/DashboardScreen/DashboardScreen.tsx @@ -4,7 +4,9 @@ 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 { + KIND_HELP, KIND_TEXT, OverviewPanel, ProgressPanel, TaskSection, +} from "../../organisms"; import { routes } from "@/lib/routes"; import { KIND_NOTE } from "./DashboardScreen.helper"; import styles from "./DashboardScreen.module.css"; @@ -76,7 +78,13 @@ export function DashboardScreen() { Elige otra clase arriba para ver las que sí hay. ) : ( - s.sections.map((t) => ) + <> + {/* Encabeza la misma columna que las tarjetas: cómo va el conjunto se lee sin + desplegar ninguna. */} + t.task)} + kind={s.kind} /> + {s.sections.map((t) => )} + )} diff --git a/frontend/components/screens/DashboardScreen/useDashboardScreen.ts b/frontend/components/screens/DashboardScreen/useDashboardScreen.ts index 5e1e8dd..73c4c60 100644 --- a/frontend/components/screens/DashboardScreen/useDashboardScreen.ts +++ b/frontend/components/screens/DashboardScreen/useDashboardScreen.ts @@ -1,7 +1,7 @@ import { useState } from "react"; import { useRuns } from "@/lib/api/hooks"; import { configured } from "@/lib/supabase"; -import type { DataKind } from "../../organisms/LevelCard"; +import { kindOf, type DataKind } from "../../organisms/LevelCard"; import { KIND_ORDER, RUN_LIMIT, buildDashboard, defaultKind, kindCounts, } from "./DashboardScreen.helper"; @@ -30,6 +30,9 @@ export function useDashboardScreen() { kind, options: KIND_ORDER.map((k) => ({ k, count: counts[k] })), sections: buildDashboard(runs, kind), + // Las mismas ejecuciones que alimentan las tarjetas, sin agrupar por nivel: el panel + // de arriba mira la tarea entera. + runs: runs.filter((r) => kindOf(r) === kind), totalRuns: runs.length, // El selector devuelve un texto: solo vale si es una de las clases que existen. onKind: (value: string) => { diff --git a/frontend/lib/ui.ts b/frontend/lib/ui.ts index 1ad7a83..18e9ee9 100644 --- a/frontend/lib/ui.ts +++ b/frontend/lib/ui.ts @@ -158,7 +158,13 @@ export function clockTime(iso: string | null | undefined): string { }); } +/* La tarea es de dónde se coge el paquete. El identificador viaja en inglés —el mismo + * que `runs.config.source`— y se traduce aquí: la pantalla no lo enseña crudo. */ export const TASK_TEXT: Record = { + table: "mesa", + conveyor: "cinta", + truck: "camión", + // Histórico, de antes de separar la tarea por fuente. induction: "inducción", palletizing: "paletizado", "paletizado-guionizado": "paletizado guionizado",