diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx
index a368533..d4707f6 100644
--- a/app/(app)/machines/[machineId]/MachineDetailClient.tsx
+++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx
@@ -110,7 +110,7 @@ export default function MachineDetailClient() {
async function load() {
try {
- const res = await fetch(`/api/machines/${machineId}`, {
+ const res = await fetch(`/api/machines/${machineId}?windowSec=10800`, {
cache: "no-store",
credentials: "include",
});
@@ -242,6 +242,77 @@ export default function MachineDetailClient() {
);
}
+
+ function MachineActivityTimeline({
+ segments,
+ windowSec,
+ }: {
+ segments: TimelineSeg[];
+ windowSec: number;
+ }) {
+ return (
+
+
+
+
Machine Activity Timeline
+
Análisis en tiempo real de ciclos de producción
+
+
{windowSec}s
+
+
+
+ {(["normal","slow","microstop","macrostop"] as const).map((k) => (
+
+
+ {BUCKET[k].label}
+
+ ))}
+
+
+
+ {/* time marks */}
+
+ 0s
+ 3h
+
+
+ {/* strip */}
+
+ {segments.length === 0 ? (
+
+ No timeline data yet.
+
+ ) : (
+ segments.map((seg, idx) => {
+ const wPct = Math.max(0.25, (seg.durationSec / windowSec) * 100); // min width for visibility
+ const meta = BUCKET[seg.state];
+
+ const glow =
+ seg.state === "microstop" || seg.state === "macrostop"
+ ? `0 0 22px ${meta.glow}`
+ : `0 0 12px ${meta.glow}`;
+
+ return (
+
+ );
+ })
+ )}
+
+
+
+ );
+ }
+
function Modal({
open,
onClose,
@@ -409,6 +480,91 @@ export default function MachineDetailClient() {
return { rows, total };
}, [cycleDerived.mapped]);
+ type TimelineState = "normal" | "slow" | "microstop" | "macrostop";
+type TimelineSeg = {
+ start: number; // ms
+ end: number; // ms
+ durationSec: number;
+ state: TimelineState;
+};
+
+function classifyGap(dtSec: number, idealSec: number): TimelineState {
+ const SLOW_X = 1.5;
+ const STOP_X = 3.0;
+ const MACRO_X = 10.0;
+
+ if (dtSec <= idealSec * SLOW_X) return "normal";
+ if (dtSec <= idealSec * STOP_X) return "slow";
+ if (dtSec <= idealSec * MACRO_X) return "microstop";
+ return "macrostop";
+}
+
+function mergeAdjacent(segs: TimelineSeg[]): TimelineSeg[] {
+ if (!segs.length) return [];
+ const out: TimelineSeg[] = [segs[0]];
+ for (let i = 1; i < segs.length; i++) {
+ const prev = out[out.length - 1];
+ const cur = segs[i];
+ // merge if same state and touching
+ if (cur.state === prev.state && cur.start <= prev.end + 1) {
+ prev.end = Math.max(prev.end, cur.end);
+ prev.durationSec = (prev.end - prev.start) / 1000;
+ } else {
+ out.push(cur);
+ }
+ }
+ return out;
+}
+
+const timeline = useMemo(() => {
+ const rows = cycles ?? [];
+ if (rows.length < 2) {
+ return { windowSec: 10800, segments: [] as TimelineSeg[], start: null as number | null, end: null as number | null };
+ }
+
+ // window: last 180s (like your screenshot)
+ const windowSec = 10800;
+ const end = rows[rows.length - 1].t;
+ const start = end - windowSec * 1000;
+
+ // keep cycles that overlap window (need one cycle before start to build first interval)
+ const idxFirst = Math.max(
+ 0,
+ rows.findIndex(r => r.t >= start) - 1
+ );
+ const sliced = rows.slice(idxFirst);
+
+ const segs: TimelineSeg[] = [];
+
+ for (let i = 1; i < sliced.length; i++) {
+ const prev = sliced[i - 1];
+ const cur = sliced[i];
+
+ const s = Math.max(prev.t, start);
+ const e = Math.min(cur.t, end);
+ if (e <= s) continue;
+
+ const dtSec = (cur.t - prev.t) / 1000;
+
+ const ideal = (cur.ideal ?? prev.ideal ?? cycleTarget ?? 0) as number;
+ if (!ideal || ideal <= 0) continue;
+
+ const state = classifyGap(dtSec, ideal);
+
+ segs.push({
+ start: s,
+ end: e,
+ durationSec: (e - s) / 1000,
+ state,
+ });
+ }
+
+ const segments = mergeAdjacent(segs);
+
+ return { windowSec, segments, start, end };
+}, [cycles, cycleTarget]);
+
+
return (
@@ -471,6 +627,10 @@ export default function MachineDetailClient() {
+
+
+
+
{/* Work order + recent events */}
diff --git a/app/api/machines/[machineId]/route.ts b/app/api/machines/[machineId]/route.ts
index 89de4fc..ce9def6 100644
--- a/app/api/machines/[machineId]/route.ts
+++ b/app/api/machines/[machineId]/route.ts
@@ -246,10 +246,36 @@ const events = normalized
.slice(0, 30);
+// ---- cycles window ----
+const url = new URL(_req.url);
+const windowSec = Number(url.searchParams.get("windowSec") ?? "10800"); // default 3h
+
+const latestKpi = machine.kpiSnapshots[0] ?? null;
+
+// If KPI cycleTime missing, fallback to DB cycles (we fetch 1 first)
+const latestCycleForIdeal = await prisma.machineCycle.findFirst({
+ where: { orgId: session.orgId, machineId },
+ orderBy: { ts: "desc" },
+ select: { theoreticalCycleTime: true },
+});
+
+const effectiveCycleTime =
+ latestKpi?.cycleTime ??
+ latestCycleForIdeal?.theoreticalCycleTime ??
+ null;
+
+// Estimate how many cycles we need to cover the window.
+// Add buffer so the chart doesn’t look “tight”.
+const estCycleSec = Math.max(1, Number(effectiveCycleTime ?? 14));
+const needed = Math.ceil(windowSec / estCycleSec) + 50;
+
+// Safety cap to avoid crazy payloads
+const takeCycles = Math.min(5000, Math.max(200, needed));
+
const rawCycles = await prisma.machineCycle.findMany({
where: { orgId: session.orgId, machineId },
orderBy: { ts: "desc" },
- take: 200,
+ take: takeCycles,
select: {
ts: true,
cycleCount: true,
@@ -265,23 +291,14 @@ const cycles = rawCycles
.slice()
.reverse()
.map((c) => ({
- ts: c.ts, // keep Date for “time ago” UI
- t: c.ts.getTime(), // numeric x-axis for charts
+ ts: c.ts,
+ t: c.ts.getTime(),
cycleCount: c.cycleCount ?? null,
- actual: c.actualCycleTime, // rename to what chart expects
+ actual: c.actualCycleTime,
ideal: c.theoreticalCycleTime ?? null,
workOrderId: c.workOrderId ?? null,
sku: c.sku ?? null,
- }
-));
-
-const latestKpi = machine.kpiSnapshots[0] ?? null;
-
-// rawCycles is ordered DESC, so [0] is the most recent cycle row
-const latestCycleIdeal = rawCycles[0]?.theoreticalCycleTime ?? null;
-
-// REAL effective value (not mock): prefer KPI if present, else fallback to cycles table
-const effectiveCycleTime = latestKpi?.cycleTime ?? latestCycleIdeal ?? null;
+ }));