Data Reliability — Handoff Prompt Problem Same machine shows different numbers in 3 places: Home UI (Node-RED): goodParts=353, OEE=77.9% Recap: goodParts=185, OEE=56% Machine detail: OEE=4.3%, "Sin datos" in 1h timeline Root cause: each view queries a different table with different logic. No single source of truth. Rule: pick one source per metric, reuse across views Metric Authoritative source Why goodParts, scrapParts (per WO) MachineWorkOrder.good_parts / scrap_parts Node-RED writes this via UPDATE work_orders SET .... It's what Home UI shows. cycleCount MachineWorkOrder.cycle_count Same reason. oee / availability / performance / quality time-weighted avg of MachineKpiSnapshot rows in window Snapshots are minute-by-minute; Node-RED already sends them. Don't recompute. Stops (count, duration) MachineEvent filtered by eventType IN (microstop, macrostop, mold-change) + data->>'status' != 'active' + !is_update && !is_auto_ack Deduped at source. Timeline segments UNION of: MachineWorkOrder status spans, MachineEvent (mold-change/micro/macro), filled with idle Only way to get continuous coverage. Backend changes /api/recap/[machineId]/route.ts and /api/recap/summary/route.ts goodParts aggregation — stop summing MachineCycle.good_delta. Instead: // For window [start, end], sum good_parts from WOs that had activity in window const wos = await prisma.machineWorkOrder.findMany({ where: { machineId, orgId, updatedAt: { gte: start } }, select: { workOrderId: true, sku: true, good_parts: true, scrap_parts: true, target_qty: true, status: true, updatedAt: true } }); const goodParts = wos.reduce((s, w) => s + (w.good_parts ?? 0), 0); const scrapParts = wos.reduce((s, w) => s + (w.scrap_parts ?? 0), 0); Optionally scope to WOs that were RUNNING during the window; but for 24h window this rarely matters. OEE aggregation — time-weighted average: const snaps = await prisma.machineKpiSnapshot.findMany({ where: { machineId, orgId, ts: { gte: start, lte: end } }, orderBy: { ts: 'asc' }, select: { ts: true, oee: true, availability: true, performance: true, quality: true } }); function weightedAvg(field: 'oee' | 'availability' | 'performance' | 'quality') { if (snaps.length === 0) return null; let totalMs = 0, sum = 0; for (let i = 0; i < snaps.length; i++) { const nextTs = (snaps[i+1]?.ts ?? end).getTime(); const dt = Math.max(0, nextTs - snaps[i].ts.getTime()); sum += (snaps[i][field] ?? 0) * dt; totalMs += dt; } return totalMs > 0 ? sum / totalMs : null; } Return null (not 0, not 100) when no snapshots. Frontend renders — for null. Stops aggregation — filter properly: const stops = await prisma.machineEvent.findMany({ where: { machineId, orgId, ts: { gte: start, lte: end }, eventType: { in: ['microstop','macrostop'] }, } }); const real = stops.filter(e => { const d = e.data as any; return d?.status !== 'active' && !d?.is_auto_ack && !d?.is_update; }); const stopsCount = real.length; const stopsMin = real.reduce((s, e) => s + (((e.data as any)?.stoppage_duration_seconds ?? 0) / 60), 0); /api/recap/[machineId]/timeline — MUST include mold-change Segment builder in priority order (higher priority wins when overlapping): mold-change segments (pair active→resolved by incidentKey, duration from data.duration_sec) macrostop segments (same pairing) microstop segments (merge runs <60s apart into cluster) production segments — derived from WO status history, use MachineWorkOrder.status transitions + MachineCycle density (no cycles for >threshold → not production) idle gap-fill Never return empty array if any event exists in window. "Sin datos" only if literally zero rows in both MachineEvent and MachineCycle for the window. Merge rules: Same-type consecutive segments separated by <30s → merge Any segment <30s duration, absorb into neighbor Return format: { range: { start, end }, segments: Array<{ type: 'production' | 'mold-change' | 'macrostop' | 'microstop' | 'idle', startMs, endMs, durationSec, label?: string, // WO id, mold ids, reason workOrderId?: string, sku?: string, reasonLabel?: string, }>, hasData: boolean // false only if literally empty } Frontend changes RecapMachineCard.tsx / Machine detail page / OverviewTimeline.tsx All three MUST consume the same endpoint and render from the same shape. Timeline in Machine detail page (app/(app)/machines/[machineId]/MachineDetailClient.tsx) currently queries its own source — refactor to call /api/recap/[machineId]/timeline with range=1h for the small timeline, range=24h for the recap. "Sin datos" fallback: render only when hasData === false. If timeline has any mold-change or stop segment, render the bar. Null handling for OEE If backend returns oee: null: