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; + }));