Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
6 changes: 5 additions & 1 deletion backend/sql/001_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 $$;
Expand Down
10 changes: 9 additions & 1 deletion backend/theker_telemetry/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions frontend/components/atoms/Donut/Donut.helper.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
28 changes: 28 additions & 0 deletions frontend/components/atoms/Donut/Donut.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 39 additions & 0 deletions frontend/components/atoms/Donut/Donut.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg className={styles.donut} style={{ maxWidth: size }} viewBox="0 0 100 100"
role="img" aria-label={label}>
{/* El anillo arranca arriba y no a las tres en punto, que es como se lee un reparto. */}
<g transform="rotate(-90 50 50)">
<circle className={styles.track} cx={50} cy={50} r={R} pathLength={C} />
{arcs.map((a) => (
<circle key={a.key} className={styles.arc} cx={50} cy={50} r={R}
stroke={a.color} strokeDasharray={a.dash} strokeDashoffset={a.offset}>
<title>{a.title}</title>
</circle>
))}
</g>
<text className={styles.value} x={50} y={50}>{centerValue}</text>
<text className={styles.label} x={50} y={63}>{centerLabel}</text>
</svg>
);
}
2 changes: 2 additions & 0 deletions frontend/components/atoms/Donut/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Donut } from "./Donut";
export type { DonutSegment } from "./Donut.helper";
11 changes: 7 additions & 4 deletions frontend/components/atoms/Trend/Trend.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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),
};
}
2 changes: 2 additions & 0 deletions frontend/components/atoms/Trend/Trend.module.css
Original file line number Diff line number Diff line change
@@ -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); }
Expand Down
15 changes: 11 additions & 4 deletions frontend/components/atoms/Trend/Trend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<svg className={cx(styles.trend, styles[tone])} viewBox={`0 0 ${g.width} ${g.height}`}
role="img" aria-label={label}>
<svg className={cx(styles.trend, styles[tone], height != null && styles.fluid)}
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} />
)}
Expand Down
2 changes: 2 additions & 0 deletions frontend/components/atoms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading