- {isUrgent
- ? t("recap.card.stoppedFor", { min: ongoingStopMin })
+
+ {machine.status === "data-loss"
+ ? t("recap.card.dataLoss", { count: machine.stateContext.untrackedCycleCount ?? 0 })
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
+ : machine.status === "stopped" && ongoingStopMin >= 5
+ ? (machine.stateContext.stoppedReason === "not_started"
+ ? t("recap.card.notStarted")
+ : t("recap.card.stoppedFor", { min: ongoingStopMin }))
+ + (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
+ : machine.status === "idle"
+ ? t("recap.card.idle")
: footerText}
diff --git a/components/recap/RecapMachineCard.tsx.bak.step5 b/components/recap/RecapMachineCard.tsx.bak.step5
new file mode 100644
index 0000000..7a56018
--- /dev/null
+++ b/components/recap/RecapMachineCard.tsx.bak.step5
@@ -0,0 +1,153 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { useI18n } from "@/lib/i18n/useI18n";
+import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
+import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
+
+type Props = {
+ machine: RecapSummaryMachine;
+ rangeStart: string;
+ rangeEnd: string;
+};
+
+const STATUS_DOT: Record
= {
+ running: "bg-emerald-400",
+ "mold-change": "bg-amber-400",
+ stopped: "bg-red-500",
+ offline: "bg-zinc-500",
+};
+
+function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
+ if (status === "running") return t("recap.status.running");
+ if (status === "mold-change") return t("recap.status.moldChange");
+ if (status === "stopped") return t("recap.status.stopped");
+ return t("recap.status.offline");
+}
+
+function toInt(value: number | null | undefined) {
+ if (value == null || Number.isNaN(value)) return 0;
+ return Math.max(0, Math.round(value));
+}
+
+export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
+ const { t, locale } = useI18n();
+ const [timeline, setTimeline] = useState(null);
+
+ const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
+ const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
+ const ongoingStopMin = machine.ongoingStopMin ?? 0;
+ const isUrgent = machine.status === "stopped" && ongoingStopMin >= 5;
+ const timelineSegments = timeline?.segments ?? machine.miniTimeline;
+ const timelineStart = timeline?.range.start ?? rangeStart;
+ const timelineEnd = timeline?.range.end ?? rangeEnd;
+ const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
+
+ const lastSeenLabel =
+ machine.lastActivityMin == null
+ ? t("common.never")
+ : t("recap.card.lastActivity", { min: toInt(machine.lastActivityMin) });
+
+ const footerText = machine.activeWorkOrderId
+ ? t("recap.card.activeWorkOrder", { id: machine.activeWorkOrderId })
+ : lastSeenLabel;
+
+ const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null;
+
+ useEffect(() => {
+ let alive = true;
+
+ async function loadTimeline() {
+ try {
+ const res = await fetch(
+ `/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`,
+ { cache: "no-store" }
+ );
+ const json = await res.json().catch(() => null);
+ if (!alive || !res.ok || !json) return;
+ setTimeline(json as RecapTimelineResponse);
+ } catch {
+ }
+ }
+
+ void loadTimeline();
+ const timer = window.setInterval(() => {
+ void loadTimeline();
+ }, 60000);
+
+ return () => {
+ alive = false;
+ window.clearInterval(timer);
+ };
+ }, [machine.machineId]);
+
+ return (
+
+
+
+
{machine.name}
+
{machine.location || t("common.na")}
+
+
+
+ {statusLabel(machine.status, t)}
+
+
+
+
+
{primaryMetric}
+
{t("recap.card.oee")}
+
+ {machine.oee == null ? {t("recap.kpi.noData")}
: null}
+
+ {zeroActivity ? {t("recap.card.noProduction")}
: null}
+
+
+ {t("recap.card.good")}: {machine.goodParts}
+ {t("recap.card.scrap")}: {machine.scrap}
+ {t("recap.card.stops")}: {machine.stopsCount}
+
+
+
+
+
+
+ {machine.moldChange?.active ? (
+
+ {t("recap.card.moldChangeActive", { min: toInt(moldMinutes) })}
+
+ ) : null}
+
+ {machine.offlineForMin != null && machine.offlineForMin > 10 ? (
+
+ {t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
+
+ ) : null}
+
+
+ {isUrgent
+ ? t("recap.card.stoppedFor", { min: ongoingStopMin })
+ + (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
+ : footerText}
+
+
+ );
+}
diff --git a/lib/i18n/en.json b/lib/i18n/en.json
index 130ff11..170d115 100644
--- a/lib/i18n/en.json
+++ b/lib/i18n/en.json
@@ -115,6 +115,11 @@
"machines.status.stopped": "STOPPED",
"machines.stoppedFor": "Stopped for {min} min",
"recap.grid.title": "Machine recap",
+ "recap.status.dataLoss": "Data Loss",
+ "recap.status.idle": "Idle",
+ "recap.card.dataLoss": "{count} untracked cycles — press START",
+ "recap.card.notStarted": "Operator hasn't pressed START",
+ "recap.card.idle": "No active work order",
"recap.grid.subtitle": "Last 24h · click to open details",
"recap.grid.updatedAgo": "Updated {sec}s ago",
"recap.grid.empty": "No machines match the current filters.",
diff --git a/lib/i18n/es-MX.json b/lib/i18n/es-MX.json
index 50b7b38..ab24b10 100644
--- a/lib/i18n/es-MX.json
+++ b/lib/i18n/es-MX.json
@@ -121,6 +121,11 @@
"recap.card.stoppedFor": "Detenida hace {min} min",
"machines.status.stopped": "DETENIDA",
"machines.stoppedFor": "Detenida hace {min} min",
+ "recap.status.dataLoss": "Sin tracking",
+ "recap.status.idle": "Inactiva",
+ "recap.card.dataLoss": "{count} ciclos sin tracking — presione INICIAR",
+ "recap.card.notStarted": "Operador no ha presionado INICIAR",
+ "recap.card.idle": "Sin orden de trabajo activa",
"recap.grid.title": "Resumen de máquinas",
"recap.grid.subtitle": "Últimas 24h · click para ver detalle",
"recap.grid.updatedAgo": "Actualizado hace {sec}s",
diff --git a/lib/recap/machineState.ts b/lib/recap/machineState.ts
new file mode 100644
index 0000000..8727026
--- /dev/null
+++ b/lib/recap/machineState.ts
@@ -0,0 +1,174 @@
+import type { TimelineEventRow } from "@/lib/recap/timeline";
+
+/**
+ * Shared classifier for machine state across /recap, /machines, /overview.
+ *
+ * State precedence (top wins):
+ * 1. OFFLINE — heartbeat dead
+ * 2. MOLD_CHANGE — operator initiated mold swap
+ * 3. STOPPED — should be producing, isn't
+ * 4. DATA_LOSS — producing but tracking off (operator forgot START)
+ * 5. IDLE — nothing loaded, nothing running, nothing expected
+ * 6. RUNNING — healthy
+ *
+ * Inputs are intentionally raw and computed by the caller, not fetched here,
+ * so this module stays pure (testable, no DB/Prisma dependency).
+ */
+
+export type MachineStateName =
+ | "offline"
+ | "mold-change"
+ | "stopped"
+ | "data-loss"
+ | "idle"
+ | "running";
+
+export type StoppedReason = "machine_fault" | "not_started";
+export type DataLossReason = "untracked";
+
+export type MachineStateResult =
+ | { state: "offline"; lastSeenMs: number | null; offlineForMin: number }
+ | {
+ state: "mold-change";
+ moldChangeStartMs: number | null;
+ moldChangeMin: number;
+ }
+ | {
+ state: "stopped";
+ reason: StoppedReason;
+ ongoingStopMin: number;
+ stopStartedAtMs: number | null;
+ }
+ | {
+ state: "data-loss";
+ reason: DataLossReason;
+ untrackedCycleCount: number;
+ untrackedSinceMs: number | null;
+ untrackedForMin: number;
+ }
+ | { state: "idle" }
+ | { state: "running" };
+
+export type MachineStateInputs = {
+ /** Heartbeat freshness — true if the Pi has been seen within the offline threshold */
+ heartbeatAlive: boolean;
+ /** Last heartbeat timestamp in ms (or null if never seen) */
+ lastSeenMs: number | null;
+ /** Computed offline duration in ms — used when heartbeatAlive is false */
+ offlineForMs: number;
+
+ /** Operator pressed START — true if latest KPI snapshot has trackingEnabled=true */
+ trackingEnabled: boolean;
+
+ /** A work order with status RUNNING or PENDING is currently assigned */
+ hasActiveWorkOrder: boolean;
+
+ /** Active mold-change event (from timeline events) */
+ activeMoldChange: { startedAtMs: number } | null;
+
+ /** Active macrostop event (from timeline events) — fires when tracking on + no cycles */
+ activeMacrostop: { startedAtMs: number } | null;
+
+ /**
+ * Untracked cycles arriving while tracking is OFF.
+ * Caller computes by counting MachineCycle rows in the last UNTRACKED_WINDOW_MS
+ * where ts > latestKpi.ts (so they're "after" the tracking-off snapshot).
+ */
+ untrackedCycles: { count: number; oldestTsMs: number | null };
+
+ /**
+ * Most recent cycle timestamp regardless of tracking — used as a sanity check
+ * for IDLE classification.
+ */
+ lastCycleTsMs: number | null;
+};
+
+// Trigger thresholds — tunable
+const DATA_LOSS_MIN_CYCLES = 5;
+const DATA_LOSS_MIN_DURATION_MS = 10 * 60 * 1000; // 10 min
+const RECENT_CYCLE_MS = 15 * 60 * 1000; // for IDLE check — "no cycles in 15 min"
+
+export function classifyMachineState(
+ inputs: MachineStateInputs,
+ nowMs: number
+): MachineStateResult {
+ // 1. OFFLINE — wins over everything. If we can't see the Pi, nothing else is reliable.
+ if (!inputs.heartbeatAlive) {
+ return {
+ state: "offline",
+ lastSeenMs: inputs.lastSeenMs,
+ offlineForMin: Math.max(0, Math.floor(inputs.offlineForMs / 60000)),
+ };
+ }
+
+ // 2. MOLD_CHANGE — operator-initiated, suppresses STOPPED/ATTENTION even if cycles missing
+ if (inputs.activeMoldChange) {
+ return {
+ state: "mold-change",
+ moldChangeStartMs: inputs.activeMoldChange.startedAtMs,
+ moldChangeMin: Math.max(
+ 0,
+ Math.floor((nowMs - inputs.activeMoldChange.startedAtMs) / 60000)
+ ),
+ };
+ }
+
+ // 3. DATA_LOSS — tracking off but cycles arriving. Operator forgot START.
+ // Check this BEFORE STOPPED because cycles ARE arriving (so the "no cycles" branch
+ // would never fire), but we still want to flag it.
+ if (!inputs.trackingEnabled && inputs.untrackedCycles.count > 0) {
+ const oldest = inputs.untrackedCycles.oldestTsMs;
+ const durationMs = oldest != null ? nowMs - oldest : 0;
+ const tripped =
+ inputs.untrackedCycles.count >= DATA_LOSS_MIN_CYCLES ||
+ durationMs >= DATA_LOSS_MIN_DURATION_MS;
+
+ if (tripped) {
+ return {
+ state: "data-loss",
+ reason: "untracked",
+ untrackedCycleCount: inputs.untrackedCycles.count,
+ untrackedSinceMs: oldest,
+ untrackedForMin: Math.max(0, Math.floor(durationMs / 60000)),
+ };
+ }
+ // Not yet tripped — fall through to other checks (likely RUNNING since cycles are coming)
+ }
+
+ // 4. STOPPED — should be producing, isn't. Two reasons:
+ // a) machine_fault: operator pressed START, macrostop event active → mechanical issue
+ // b) not_started: operator never pressed START but a WO is loaded
+ if (inputs.activeMacrostop && inputs.trackingEnabled) {
+ const startedAt = inputs.activeMacrostop.startedAtMs;
+ return {
+ state: "stopped",
+ reason: "machine_fault",
+ ongoingStopMin: Math.max(0, Math.floor((nowMs - startedAt) / 60000)),
+ stopStartedAtMs: startedAt,
+ };
+ }
+
+ if (inputs.hasActiveWorkOrder && !inputs.trackingEnabled) {
+ // Operator hasn't started production despite a loaded WO.
+ // We don't have a precise "since when" for this — best estimate is "since latest
+ // KPI snapshot reported trackingEnabled=false," but that's not in the inputs.
+ // For now, report ongoingStopMin=0 and let the caller refine if needed.
+ return {
+ state: "stopped",
+ reason: "not_started",
+ ongoingStopMin: 0,
+ stopStartedAtMs: null,
+ };
+ }
+
+ // 5. IDLE — no one expects this machine to be doing anything right now.
+ // No tracking, no WO, no recent cycles. Calm gray.
+ const cycledRecently =
+ inputs.lastCycleTsMs != null && nowMs - inputs.lastCycleTsMs <= RECENT_CYCLE_MS;
+ if (!inputs.trackingEnabled && !inputs.hasActiveWorkOrder && !cycledRecently) {
+ return { state: "idle" };
+ }
+
+ // 6. RUNNING — default. Tracking on, WO loaded, cycles flowing.
+ return { state: "running" };
+}
\ No newline at end of file
diff --git a/lib/recap/redesign.ts b/lib/recap/redesign.ts
index f399bc4..9a31ce8 100644
--- a/lib/recap/redesign.ts
+++ b/lib/recap/redesign.ts
@@ -9,6 +9,7 @@ import {
type TimelineCycleRow,
type TimelineEventRow,
} from "@/lib/recap/timeline";
+import { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import type {
RecapDetailResponse,
@@ -16,6 +17,7 @@ import type {
RecapMachineDetail,
RecapMachineStatus,
RecapRangeMode,
+ RecapStateContext,
RecapSummaryMachine,
RecapSummaryResponse,
} from "@/lib/recap/types";
@@ -175,22 +177,26 @@ function addDays(input: { year: number; month: number; day: number }, days: numb
};
}
-// Active stoppage = freshest macrostop episode whose latest event is "active"
-// and whose latest event timestamp is within ACTIVE_STALE_MS of rangeEnd.
-// Mirrors the same rules used by lib/recap/timeline.ts so the card status
-// agrees with the timeline rendering.
-const STOPPAGE_ACTIVE_STALE_MS = 2 * 60 * 1000;
+// Detect active episodes (macrostop, mold-change) from event rows.
+// Returns the latest non-auto-ack episode whose final status is "active"
+// and that's been refreshed within ACTIVE_STALE_MS.
+const ACTIVE_STALE_MS = 2 * 60 * 1000;
-function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: number) {
+type ActiveEpisode = { startedAtMs: number; lastTsMs: number };
+
+function detectActiveEpisode(
+ events: TimelineEventRow[] | undefined,
+ eventType: "macrostop" | "mold-change",
+ endMs: number
+): ActiveEpisode | null {
if (!events || events.length === 0) return null;
- type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string };
+ type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null };
const episodes = new Map();
for (const event of events) {
- if (String(event.eventType || "").toLowerCase() !== "macrostop") continue;
+ if (String(event.eventType || "").toLowerCase() !== eventType) continue;
- // Defensive: parse data the same way timeline.ts does.
let parsed: unknown = event.data;
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
@@ -200,7 +206,6 @@ function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: nu
? (parsed as Record)
: {};
- // Drop only the auto-ack pings (same rule as timeline.ts Fix B).
const isAutoAck =
data.is_auto_ack === true ||
data.isAutoAck === true ||
@@ -210,12 +215,18 @@ function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: nu
const status = String(data.status ?? "").trim().toLowerCase();
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
- || `macrostop:${event.ts.getTime()}`;
+ || `${eventType}:${event.ts.getTime()}`;
const tsMs = event.ts.getTime();
+ const lastCycleTs = Number(data.last_cycle_timestamp);
const existing = episodes.get(incidentKey);
if (!existing) {
- episodes.set(incidentKey, { firstTsMs: tsMs, lastTsMs: tsMs, lastStatus: status });
+ episodes.set(incidentKey, {
+ firstTsMs: tsMs,
+ lastTsMs: tsMs,
+ lastStatus: status,
+ lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null,
+ });
continue;
}
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
@@ -225,39 +236,108 @@ function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: nu
}
}
- let activeOngoingMin = 0;
+ let best: ActiveEpisode | null = null;
for (const ep of episodes.values()) {
if (ep.lastStatus !== "active") continue;
- if (endMs - ep.lastTsMs > STOPPAGE_ACTIVE_STALE_MS) continue;
- const ongoingMin = Math.max(0, Math.floor((endMs - ep.firstTsMs) / 60000));
- if (ongoingMin > activeOngoingMin) activeOngoingMin = ongoingMin;
+ if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue;
+ // Prefer the freshest active episode (highest lastTsMs)
+ if (!best || ep.lastTsMs > best.lastTsMs) {
+ best = {
+ startedAtMs: ep.lastCycleTs ?? ep.firstTsMs,
+ lastTsMs: ep.lastTsMs,
+ };
+ }
}
-
- return activeOngoingMin > 0 ? activeOngoingMin : null;
+ return best;
}
-function statusFromMachine(machine: RecapMachine, endMs: number, events?: TimelineEventRow[]) {
+function statusFromMachine(
+ machine: RecapMachine,
+ endMs: number,
+ events?: TimelineEventRow[]
+): {
+ status: RecapMachineStatus;
+ result: MachineStateResult;
+ stateContext: RecapStateContext;
+ lastSeenMs: number | null;
+ offlineForMin: number | null;
+ ongoingStopMin: number | null;
+} {
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
- const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
+ const heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS;
- // ongoingStopMin from the legacy heartbeat-based path (typically null) OR
- // from the macrostop event detection (preferred — accurate)
- const macrostopOngoingMin = detectActiveMacrostop(events, endMs);
- const legacyOngoingStopMin = machine.downtime.ongoingStopMin ?? 0;
- const ongoingStopMin = macrostopOngoingMin ?? (legacyOngoingStopMin > 0 ? legacyOngoingStopMin : null);
+ const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs);
+ const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs);
- const moldActive = machine.workOrders.moldChangeInProgress;
+ // Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries
+ // we don't yet plumb here. We approximate from the legacy fields:
+ // - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on)
+ // OR when an active WO exists and machine.workOrders.moldChangeInProgress is false.
+ // This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read.
+ // - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI)
+ //
+ // Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking
+ // is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet.
+ // IDLE fires correctly when there's no WO and no recent activity.
+ const hasActiveWorkOrder = machine.workOrders.active != null;
+ const trackingEnabledApprox = hasActiveWorkOrder; // see comment above
- let status: RecapMachineStatus = "running";
- if (offline) status = "offline";
- else if (moldActive) status = "mold-change";
- else if (ongoingStopMin != null && ongoingStopMin > 0) status = "stopped";
+ const lastCycleTsMs = (() => {
+ // Best-effort: use the machine's heartbeat as a "recent activity" proxy.
+ // The Pi only heartbeats every minute regardless of cycles, so this is a weak signal.
+ // Round 3 will pass the actual latest cycle ts.
+ return lastSeenMs;
+ })();
+
+ const result = classifyMachineState(
+ {
+ heartbeatAlive,
+ lastSeenMs,
+ offlineForMs,
+ trackingEnabled: trackingEnabledApprox,
+ hasActiveWorkOrder,
+ activeMoldChange,
+ activeMacrostop,
+ untrackedCycles: { count: 0, oldestTsMs: null },
+ lastCycleTsMs,
+ },
+ endMs
+ );
+
+ // Map the rich classifier result back to the existing RecapMachineStatus union
+ const status: RecapMachineStatus = result.state;
+
+ // Pull common fields out for the caller's convenience
+ let ongoingStopMin: number | null = null;
+ if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin;
+
+ let stateContext: RecapStateContext = {
+ stoppedReason: null,
+ dataLossReason: null,
+ untrackedCycleCount: null,
+ };
+
+ if (result.state === "stopped") {
+ stateContext = {
+ stoppedReason: result.reason,
+ dataLossReason: null,
+ untrackedCycleCount: null,
+ };
+ } else if (result.state === "data-loss") {
+ stateContext = {
+ stoppedReason: null,
+ dataLossReason: result.reason,
+ untrackedCycleCount: result.untrackedCycleCount,
+ };
+ }
return {
status,
+ result,
+ stateContext,
lastSeenMs,
- offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
+ offlineForMin: result.state === "offline" ? result.offlineForMin : null,
ongoingStopMin,
};
}
@@ -366,6 +446,7 @@ function toSummaryMachine(params: {
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
offlineForMin: status.offlineForMin,
ongoingStopMin: status.ongoingStopMin,
+ stateContext: status.stateContext,
activeWorkOrderId: machine.workOrders.active?.id ?? null,
moldChange: {
active: machine.workOrders.moldChangeInProgress,
@@ -704,6 +785,8 @@ async function computeRecapMachineDetail(params: {
lastSeenMs: status.lastSeenMs,
offlineForMin: status.offlineForMin,
ongoingStopMin: status.ongoingStopMin,
+ stateContext: status.stateContext,
+
moldChange: {
active: machine.workOrders.moldChangeInProgress,
startMs: machine.workOrders.moldChangeStartMs,
diff --git a/lib/recap/redesign.ts.bak.step3 b/lib/recap/redesign.ts.bak.step3
new file mode 100644
index 0000000..f399bc4
--- /dev/null
+++ b/lib/recap/redesign.ts.bak.step3
@@ -0,0 +1,848 @@
+import { unstable_cache } from "next/cache";
+import { prisma } from "@/lib/prisma";
+import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
+import { getRecapDataCached } from "@/lib/recap/getRecapData";
+import {
+ buildTimelineSegments,
+ compressTimelineSegments,
+ TIMELINE_EVENT_TYPES,
+ type TimelineCycleRow,
+ type TimelineEventRow,
+} from "@/lib/recap/timeline";
+import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
+import type {
+ RecapDetailResponse,
+ RecapMachine,
+ RecapMachineDetail,
+ RecapMachineStatus,
+ RecapRangeMode,
+ RecapSummaryMachine,
+ RecapSummaryResponse,
+} from "@/lib/recap/types";
+
+type DetailRangeInput = {
+ mode?: string | null;
+ start?: string | null;
+ end?: string | null;
+};
+
+const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
+const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
+const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
+const RECAP_CACHE_TTL_SEC = 60;
+const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
+const WEEKDAY_KEY_MAP: Record = {
+ Mon: "mon",
+ Tue: "tue",
+ Wed: "wed",
+ Thu: "thu",
+ Fri: "fri",
+ Sat: "sat",
+ Sun: "sun",
+};
+
+function round2(value: number) {
+ return Math.round(value * 100) / 100;
+}
+
+function parseDate(input?: string | null) {
+ if (!input) return null;
+ const n = Number(input);
+ if (Number.isFinite(n)) {
+ const d = new Date(n);
+ return Number.isFinite(d.getTime()) ? d : null;
+ }
+ const d = new Date(input);
+ return Number.isFinite(d.getTime()) ? d : null;
+}
+
+function parseHours(input: string | null) {
+ const parsed = Math.trunc(Number(input ?? "24"));
+ if (!Number.isFinite(parsed)) return 24;
+ return Math.max(1, Math.min(72, parsed));
+}
+
+function parseTimeMinutes(input?: string | null) {
+ if (!input) return null;
+ const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
+ if (!match) return null;
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
+ return null;
+ }
+ return hours * 60 + minutes;
+}
+
+function getLocalParts(ts: Date, timeZone: string) {
+ try {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ weekday: "short",
+ hour12: false,
+ }).formatToParts(ts);
+
+ const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
+ const year = Number(value("year"));
+ const month = Number(value("month"));
+ const day = Number(value("day"));
+ const hour = Number(value("hour"));
+ const minute = Number(value("minute"));
+ const weekday = value("weekday");
+
+ return {
+ year,
+ month,
+ day,
+ hour,
+ minute,
+ weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
+ minutesOfDay: hour * 60 + minute,
+ };
+ } catch {
+ return {
+ year: ts.getUTCFullYear(),
+ month: ts.getUTCMonth() + 1,
+ day: ts.getUTCDate(),
+ hour: ts.getUTCHours(),
+ minute: ts.getUTCMinutes(),
+ weekday: WEEKDAY_KEYS[ts.getUTCDay()],
+ minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
+ };
+ }
+}
+
+function parseOffsetMinutes(offsetLabel: string | null) {
+ if (!offsetLabel) return null;
+ const normalized = offsetLabel.replace("UTC", "GMT");
+ const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
+ if (!match) return null;
+ const sign = match[1] === "-" ? -1 : 1;
+ const hour = Number(match[2]);
+ const minute = Number(match[3] ?? "0");
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
+ return sign * (hour * 60 + minute);
+}
+
+function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
+ try {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ timeZoneName: "shortOffset",
+ hour: "2-digit",
+ }).formatToParts(utcDate);
+ const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
+ return parseOffsetMinutes(offsetPart);
+ } catch {
+ return null;
+ }
+}
+
+function zonedToUtcDate(input: {
+ year: number;
+ month: number;
+ day: number;
+ hours: number;
+ minutes: number;
+ timeZone: string;
+}) {
+ const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
+ const guessDate = new Date(baseUtc);
+ const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
+ if (offsetA == null) return guessDate;
+
+ let corrected = new Date(baseUtc - offsetA * 60000);
+ const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
+ if (offsetB != null && offsetB !== offsetA) {
+ corrected = new Date(baseUtc - offsetB * 60000);
+ }
+
+ return corrected;
+}
+
+function addDays(input: { year: number; month: number; day: number }, days: number) {
+ const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
+ base.setUTCDate(base.getUTCDate() + days);
+ return {
+ year: base.getUTCFullYear(),
+ month: base.getUTCMonth() + 1,
+ day: base.getUTCDate(),
+ };
+}
+
+// Active stoppage = freshest macrostop episode whose latest event is "active"
+// and whose latest event timestamp is within ACTIVE_STALE_MS of rangeEnd.
+// Mirrors the same rules used by lib/recap/timeline.ts so the card status
+// agrees with the timeline rendering.
+const STOPPAGE_ACTIVE_STALE_MS = 2 * 60 * 1000;
+
+function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: number) {
+ if (!events || events.length === 0) return null;
+
+ type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string };
+ const episodes = new Map();
+
+ for (const event of events) {
+ if (String(event.eventType || "").toLowerCase() !== "macrostop") continue;
+
+ // Defensive: parse data the same way timeline.ts does.
+ let parsed: unknown = event.data;
+ if (typeof parsed === "string") {
+ try { parsed = JSON.parse(parsed); } catch { parsed = null; }
+ }
+ const data: Record =
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : {};
+
+ // Drop only the auto-ack pings (same rule as timeline.ts Fix B).
+ const isAutoAck =
+ data.is_auto_ack === true ||
+ data.isAutoAck === true ||
+ data.is_auto_ack === "true" ||
+ data.isAutoAck === "true";
+ if (isAutoAck) continue;
+
+ const status = String(data.status ?? "").trim().toLowerCase();
+ const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
+ || `macrostop:${event.ts.getTime()}`;
+ const tsMs = event.ts.getTime();
+
+ const existing = episodes.get(incidentKey);
+ if (!existing) {
+ episodes.set(incidentKey, { firstTsMs: tsMs, lastTsMs: tsMs, lastStatus: status });
+ continue;
+ }
+ existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
+ if (tsMs >= existing.lastTsMs) {
+ existing.lastTsMs = tsMs;
+ existing.lastStatus = status;
+ }
+ }
+
+ let activeOngoingMin = 0;
+ for (const ep of episodes.values()) {
+ if (ep.lastStatus !== "active") continue;
+ if (endMs - ep.lastTsMs > STOPPAGE_ACTIVE_STALE_MS) continue;
+ const ongoingMin = Math.max(0, Math.floor((endMs - ep.firstTsMs) / 60000));
+ if (ongoingMin > activeOngoingMin) activeOngoingMin = ongoingMin;
+ }
+
+ return activeOngoingMin > 0 ? activeOngoingMin : null;
+}
+
+function statusFromMachine(machine: RecapMachine, endMs: number, events?: TimelineEventRow[]) {
+ const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
+ const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
+ const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
+
+ // ongoingStopMin from the legacy heartbeat-based path (typically null) OR
+ // from the macrostop event detection (preferred — accurate)
+ const macrostopOngoingMin = detectActiveMacrostop(events, endMs);
+ const legacyOngoingStopMin = machine.downtime.ongoingStopMin ?? 0;
+ const ongoingStopMin = macrostopOngoingMin ?? (legacyOngoingStopMin > 0 ? legacyOngoingStopMin : null);
+
+ const moldActive = machine.workOrders.moldChangeInProgress;
+
+ let status: RecapMachineStatus = "running";
+ if (offline) status = "offline";
+ else if (moldActive) status = "mold-change";
+ else if (ongoingStopMin != null && ongoingStopMin > 0) status = "stopped";
+
+ return {
+ status,
+ lastSeenMs,
+ offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
+ ongoingStopMin,
+ };
+}
+
+async function loadTimelineRowsForMachines(params: {
+ orgId: string;
+ machineIds: string[];
+ start: Date;
+ end: Date;
+}) {
+ if (!params.machineIds.length) {
+ return {
+ cyclesByMachine: new Map(),
+ eventsByMachine: new Map(),
+ };
+ }
+
+ const [cycles, events] = await Promise.all([
+ prisma.machineCycle.findMany({
+ where: {
+ orgId: params.orgId,
+ machineId: { in: params.machineIds },
+ ts: {
+ gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
+ lte: params.end,
+ },
+ },
+ orderBy: [{ machineId: "asc" }, { ts: "asc" }],
+ select: {
+ machineId: true,
+ ts: true,
+ cycleCount: true,
+ actualCycleTime: true,
+ workOrderId: true,
+ sku: true,
+ },
+ }),
+ prisma.machineEvent.findMany({
+ where: {
+ orgId: params.orgId,
+ machineId: { in: params.machineIds },
+ eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
+ ts: {
+ gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
+ lte: params.end,
+ },
+ },
+ orderBy: [{ machineId: "asc" }, { ts: "asc" }],
+ select: {
+ machineId: true,
+ ts: true,
+ eventType: true,
+ data: true,
+ },
+ }),
+ ]);
+
+ const cyclesByMachine = new Map();
+ const eventsByMachine = new Map();
+
+ for (const row of cycles) {
+ const list = cyclesByMachine.get(row.machineId) ?? [];
+ list.push({
+ ts: row.ts,
+ cycleCount: row.cycleCount,
+ actualCycleTime: row.actualCycleTime,
+ workOrderId: row.workOrderId,
+ sku: row.sku,
+ });
+ cyclesByMachine.set(row.machineId, list);
+ }
+
+ for (const row of events) {
+ const list = eventsByMachine.get(row.machineId) ?? [];
+ list.push({
+ ts: row.ts,
+ eventType: row.eventType,
+ data: row.data,
+ });
+ eventsByMachine.set(row.machineId, list);
+ }
+
+ return { cyclesByMachine, eventsByMachine };
+}
+
+function toSummaryMachine(params: {
+ machine: RecapMachine;
+ miniTimeline: ReturnType;
+ rangeEndMs: number;
+ events?: TimelineEventRow[];
+}): RecapSummaryMachine {
+ const { machine, miniTimeline, rangeEndMs, events } = params;
+ const status = statusFromMachine(machine, rangeEndMs, events);
+
+ return {
+ machineId: machine.machineId,
+ name: machine.machineName,
+ location: machine.location,
+ status: status.status,
+ oee: machine.oee.avg,
+ goodParts: machine.production.goodParts,
+ scrap: machine.production.scrapParts,
+ stopsCount: machine.downtime.stopsCount,
+ lastSeenMs: status.lastSeenMs,
+ lastActivityMin:
+ status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
+ offlineForMin: status.offlineForMin,
+ ongoingStopMin: status.ongoingStopMin,
+ activeWorkOrderId: machine.workOrders.active?.id ?? null,
+ moldChange: {
+ active: machine.workOrders.moldChangeInProgress,
+ startMs: machine.workOrders.moldChangeStartMs,
+ elapsedMin:
+ machine.workOrders.moldChangeStartMs == null
+ ? null
+ : Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
+ },
+ miniTimeline,
+ };
+}
+
+async function computeRecapSummary(params: { orgId: string; hours: number }) {
+ const now = new Date();
+ const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
+ const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);
+
+ const recap = await getRecapDataCached({
+ orgId: params.orgId,
+ start,
+ end,
+ });
+
+ const machineIds = recap.machines.map((machine) => machine.machineId);
+ const timelineRows = await loadTimelineRowsForMachines({
+ orgId: params.orgId,
+ machineIds,
+ start,
+ end,
+ });
+
+ const machines = recap.machines.map((machine) => {
+ const segments = buildTimelineSegments({
+ cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
+ events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
+ rangeStart: start,
+ rangeEnd: end,
+ });
+ const miniTimeline = compressTimelineSegments({
+ segments,
+ rangeStart: start,
+ rangeEnd: end,
+ maxSegments: 60,
+ });
+
+ return toSummaryMachine({
+ machine,
+ miniTimeline,
+ rangeEndMs: end.getTime(),
+ events: timelineRows.eventsByMachine.get(machine.machineId),
+ });
+ });
+
+ const response: RecapSummaryResponse = {
+ generatedAt: new Date().toISOString(),
+ range: {
+ start: start.toISOString(),
+ end: end.toISOString(),
+ hours: params.hours,
+ },
+ machines,
+ };
+
+ return response;
+}
+
+function normalizedRangeMode(mode?: string | null): RecapRangeMode {
+ const raw = String(mode ?? "").trim().toLowerCase();
+ if (raw === "shift") return "shift";
+ if (raw === "yesterday") return "yesterday";
+ if (raw === "custom") return "custom";
+ return "24h";
+}
+
+async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
+ const settings = await prisma.orgSettings.findUnique({
+ where: { orgId: params.orgId },
+ select: {
+ timezone: true,
+ shiftScheduleOverridesJson: true,
+ },
+ });
+ const shifts = await prisma.orgShift.findMany({
+ where: { orgId: params.orgId },
+ orderBy: { sortOrder: "asc" },
+ select: {
+ name: true,
+ startTime: true,
+ endTime: true,
+ enabled: true,
+ sortOrder: true,
+ },
+ });
+
+ const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
+ if (!enabledShifts.length) {
+ return {
+ hasEnabledShifts: false,
+ range: null,
+ } as const;
+ }
+
+ const timeZone = settings?.timezone || "UTC";
+ const local = getLocalParts(params.now, timeZone);
+ const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
+ const dayOverrides = overrides?.[local.weekday];
+ const activeShifts = (dayOverrides?.length
+ ? dayOverrides.map((shift) => ({
+ enabled: shift.enabled !== false,
+ start: shift.start,
+ end: shift.end,
+ }))
+ : enabledShifts.map((shift) => ({
+ enabled: shift.enabled !== false,
+ start: shift.startTime,
+ end: shift.endTime,
+ }))
+ ).filter((shift) => shift.enabled);
+
+ for (const shift of activeShifts) {
+ const startMin = parseTimeMinutes(shift.start ?? null);
+ const endMin = parseTimeMinutes(shift.end ?? null);
+ if (startMin == null || endMin == null) continue;
+
+ const minutesNow = local.minutesOfDay;
+ let inRange = false;
+ let startDate = { year: local.year, month: local.month, day: local.day };
+ let endDate = { year: local.year, month: local.month, day: local.day };
+
+ if (startMin <= endMin) {
+ inRange = minutesNow >= startMin && minutesNow < endMin;
+ } else {
+ inRange = minutesNow >= startMin || minutesNow < endMin;
+ if (minutesNow >= startMin) {
+ endDate = addDays(endDate, 1);
+ } else {
+ startDate = addDays(startDate, -1);
+ }
+ }
+
+ if (!inRange) continue;
+
+ const start = zonedToUtcDate({
+ ...startDate,
+ hours: Math.floor(startMin / 60),
+ minutes: startMin % 60,
+ timeZone,
+ });
+ const shiftEndUtc = zonedToUtcDate({
+ ...endDate,
+ hours: Math.floor(endMin / 60),
+ minutes: endMin % 60,
+ timeZone,
+ });
+
+ if (shiftEndUtc <= start) continue;
+
+ // Cap end at "now" so we render shift-so-far, not shift-as-planned.
+ // Without cap:
+ // - timeline fills future minutes with idle (visual lie)
+ // - offline calc = (shift_end_future - last_seen) = looks 5h offline
+ // even on a machine producing right now
+ const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
+
+ return {
+ hasEnabledShifts: true,
+ range: { start, end },
+ };
+ }
+
+ return {
+ hasEnabledShifts: true,
+ range: null,
+ } as const;
+}
+
+async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
+ const now = new Date(Math.floor(Date.now() / 60000) * 60000);
+ const requestedMode = normalizedRangeMode(params.input.mode);
+ const shiftEnabledCount = await prisma.orgShift.count({
+ where: {
+ orgId: params.orgId,
+ enabled: { not: false },
+ },
+ });
+ const shiftAvailable = shiftEnabledCount > 0;
+
+ if (requestedMode === "custom") {
+ const start = parseDate(params.input.start);
+ const end = parseDate(params.input.end);
+ if (start && end && end > start) {
+ return {
+ requestedMode,
+ mode: requestedMode,
+ start,
+ end,
+ shiftAvailable,
+ } as const;
+ }
+ }
+
+ if (requestedMode === "yesterday") {
+ const settings = await prisma.orgSettings.findUnique({
+ where: { orgId: params.orgId },
+ select: { timezone: true },
+ });
+ const timeZone = settings?.timezone || "America/Mexico_City";
+ const localNow = getLocalParts(now, timeZone);
+ const today = { year: localNow.year, month: localNow.month, day: localNow.day };
+ const yesterday = addDays(today, -1);
+ const start = zonedToUtcDate({
+ ...yesterday,
+ hours: 0,
+ minutes: 0,
+ timeZone,
+ });
+ const end = zonedToUtcDate({
+ ...today,
+ hours: 0,
+ minutes: 0,
+ timeZone,
+ });
+ return {
+ requestedMode,
+ mode: requestedMode,
+ start,
+ end,
+ shiftAvailable,
+ } as const;
+ }
+
+ if (requestedMode === "shift") {
+ const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
+ if (shiftRange.range) {
+ return {
+ requestedMode,
+ mode: requestedMode,
+ start: shiftRange.range.start,
+ end: shiftRange.range.end,
+ shiftAvailable,
+ } as const;
+ }
+ if (!shiftRange.hasEnabledShifts) {
+ return {
+ requestedMode,
+ mode: "24h" as const,
+ start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
+ end: now,
+ shiftAvailable,
+ fallbackReason: "shift-unavailable" as const,
+ } as const;
+ }
+ return {
+ requestedMode,
+ mode: "24h" as const,
+ start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
+ end: now,
+ shiftAvailable,
+ fallbackReason: "shift-inactive" as const,
+ } as const;
+ }
+
+ return {
+ requestedMode,
+ mode: "24h" as const,
+ start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
+ end: now,
+ shiftAvailable,
+ } as const;
+}
+
+async function computeRecapMachineDetail(params: {
+ orgId: string;
+ machineId: string;
+ range: {
+ requestedMode: RecapRangeMode;
+ mode: RecapRangeMode;
+ start: Date;
+ end: Date;
+ shiftAvailable: boolean;
+ fallbackReason?: "shift-unavailable" | "shift-inactive";
+ };
+}) {
+ const { range } = params;
+
+ const recap = await getRecapDataCached({
+ orgId: params.orgId,
+ machineId: params.machineId,
+ start: range.start,
+ end: range.end,
+ });
+
+ const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null;
+ if (!machine) return null;
+
+ const timelineRows = await loadTimelineRowsForMachines({
+ orgId: params.orgId,
+ machineIds: [params.machineId],
+ start: range.start,
+ end: range.end,
+ });
+
+ const timeline = buildTimelineSegments({
+ cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [],
+ events: timelineRows.eventsByMachine.get(params.machineId) ?? [],
+ rangeStart: range.start,
+ rangeEnd: range.end,
+ });
+
+ const status = statusFromMachine(
+ machine,
+ range.end.getTime(),
+ timelineRows.eventsByMachine.get(params.machineId)
+ );
+
+ const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
+ const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
+ reasonLabel: row.reasonLabel,
+ minutes: row.minutes,
+ count: row.count,
+ percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0,
+ }));
+
+ const machineDetail: RecapMachineDetail = {
+ machineId: machine.machineId,
+ name: machine.machineName,
+ location: machine.location,
+ status: status.status,
+ oee: machine.oee.avg,
+ goodParts: machine.production.goodParts,
+ scrap: machine.production.scrapParts,
+ stopsCount: machine.downtime.stopsCount,
+ stopMinutes: downtimeTotalMin,
+ activeWorkOrderId: machine.workOrders.active?.id ?? null,
+ lastSeenMs: status.lastSeenMs,
+ offlineForMin: status.offlineForMin,
+ ongoingStopMin: status.ongoingStopMin,
+ moldChange: {
+ active: machine.workOrders.moldChangeInProgress,
+ startMs: machine.workOrders.moldChangeStartMs,
+ },
+ timeline,
+ productionBySku: machine.production.bySku,
+ downtimeTop,
+ workOrders: {
+ completed: machine.workOrders.completed,
+ active: machine.workOrders.active,
+ },
+ heartbeat: {
+ lastSeenAt: machine.heartbeat.lastSeenAt,
+ uptimePct: machine.heartbeat.uptimePct,
+ connectionStatus: status.status === "offline" ? "offline" : "online",
+ },
+ };
+
+ const response: RecapDetailResponse = {
+ generatedAt: new Date().toISOString(),
+ range: {
+ requestedMode: range.requestedMode,
+ mode: range.mode,
+ start: range.start.toISOString(),
+ end: range.end.toISOString(),
+ shiftAvailable: range.shiftAvailable,
+ fallbackReason: range.fallbackReason,
+ },
+ machine: machineDetail,
+ };
+
+ return response;
+}
+
+function summaryCacheKey(params: { orgId: string; hours: number }) {
+ return ["recap-summary-v1", params.orgId, String(params.hours)];
+}
+
+function detailCacheKey(params: {
+ orgId: string;
+ machineId: string;
+ requestedMode: RecapRangeMode;
+ mode: RecapRangeMode;
+ shiftAvailable: boolean;
+ fallbackReason?: "shift-unavailable" | "shift-inactive";
+ startMs: number;
+ endMs: number;
+}) {
+ return [
+ "recap-detail-v1",
+ params.orgId,
+ params.machineId,
+ params.requestedMode,
+ params.mode,
+ params.shiftAvailable ? "shift-on" : "shift-off",
+ params.fallbackReason ?? "",
+ String(Math.trunc(params.startMs / 60000)),
+ String(Math.trunc(params.endMs / 60000)),
+ ];
+}
+
+export function parseRecapSummaryHours(raw: string | null) {
+ return parseHours(raw);
+}
+
+export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record) {
+ if (searchParams instanceof URLSearchParams) {
+ return {
+ mode: searchParams.get("range") ?? undefined,
+ start: searchParams.get("start") ?? undefined,
+ end: searchParams.get("end") ?? undefined,
+ };
+ }
+
+ const pick = (key: string) => {
+ const value = searchParams[key];
+ if (Array.isArray(value)) return value[0] ?? undefined;
+ return value ?? undefined;
+ };
+
+ return {
+ mode: pick("range"),
+ start: pick("start"),
+ end: pick("end"),
+ };
+}
+
+export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
+ const cache = unstable_cache(
+ () => computeRecapSummary(params),
+ summaryCacheKey(params),
+ {
+ revalidate: RECAP_CACHE_TTL_SEC,
+ tags: [`recap:${params.orgId}`],
+ }
+ );
+
+ return cache();
+}
+
+export async function getRecapMachineDetailCached(params: {
+ orgId: string;
+ machineId: string;
+ input: DetailRangeInput;
+}) {
+ const resolved = await resolveDetailRange({
+ orgId: params.orgId,
+ input: params.input,
+ });
+
+ const cache = unstable_cache(
+ () =>
+ computeRecapMachineDetail({
+ orgId: params.orgId,
+ machineId: params.machineId,
+ range: {
+ requestedMode: resolved.requestedMode,
+ mode: resolved.mode,
+ start: resolved.start,
+ end: resolved.end,
+ shiftAvailable: resolved.shiftAvailable,
+ fallbackReason: resolved.fallbackReason,
+ },
+ }),
+ detailCacheKey({
+ orgId: params.orgId,
+ machineId: params.machineId,
+ requestedMode: resolved.requestedMode,
+ mode: resolved.mode,
+ shiftAvailable: resolved.shiftAvailable,
+ fallbackReason: resolved.fallbackReason,
+ startMs: resolved.start.getTime(),
+ endMs: resolved.end.getTime(),
+ }),
+ {
+ revalidate: RECAP_CACHE_TTL_SEC,
+ tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
+ }
+ );
+
+ return cache();
+}
diff --git a/lib/recap/redesign.ts.bak.step4 b/lib/recap/redesign.ts.bak.step4
new file mode 100644
index 0000000..777573e
--- /dev/null
+++ b/lib/recap/redesign.ts.bak.step4
@@ -0,0 +1,905 @@
+import { unstable_cache } from "next/cache";
+import { prisma } from "@/lib/prisma";
+import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
+import { getRecapDataCached } from "@/lib/recap/getRecapData";
+import {
+ buildTimelineSegments,
+ compressTimelineSegments,
+ TIMELINE_EVENT_TYPES,
+ type TimelineCycleRow,
+ type TimelineEventRow,
+} from "@/lib/recap/timeline";
+import { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState";
+import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
+import type {
+ RecapDetailResponse,
+ RecapMachine,
+ RecapMachineDetail,
+ RecapMachineStatus,
+ RecapRangeMode,
+ RecapSummaryMachine,
+ RecapSummaryResponse,
+} from "@/lib/recap/types";
+
+type DetailRangeInput = {
+ mode?: string | null;
+ start?: string | null;
+ end?: string | null;
+};
+
+const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
+const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
+const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
+const RECAP_CACHE_TTL_SEC = 60;
+const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
+const WEEKDAY_KEY_MAP: Record = {
+ Mon: "mon",
+ Tue: "tue",
+ Wed: "wed",
+ Thu: "thu",
+ Fri: "fri",
+ Sat: "sat",
+ Sun: "sun",
+};
+
+function round2(value: number) {
+ return Math.round(value * 100) / 100;
+}
+
+function parseDate(input?: string | null) {
+ if (!input) return null;
+ const n = Number(input);
+ if (Number.isFinite(n)) {
+ const d = new Date(n);
+ return Number.isFinite(d.getTime()) ? d : null;
+ }
+ const d = new Date(input);
+ return Number.isFinite(d.getTime()) ? d : null;
+}
+
+function parseHours(input: string | null) {
+ const parsed = Math.trunc(Number(input ?? "24"));
+ if (!Number.isFinite(parsed)) return 24;
+ return Math.max(1, Math.min(72, parsed));
+}
+
+function parseTimeMinutes(input?: string | null) {
+ if (!input) return null;
+ const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
+ if (!match) return null;
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
+ return null;
+ }
+ return hours * 60 + minutes;
+}
+
+function getLocalParts(ts: Date, timeZone: string) {
+ try {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ weekday: "short",
+ hour12: false,
+ }).formatToParts(ts);
+
+ const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
+ const year = Number(value("year"));
+ const month = Number(value("month"));
+ const day = Number(value("day"));
+ const hour = Number(value("hour"));
+ const minute = Number(value("minute"));
+ const weekday = value("weekday");
+
+ return {
+ year,
+ month,
+ day,
+ hour,
+ minute,
+ weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
+ minutesOfDay: hour * 60 + minute,
+ };
+ } catch {
+ return {
+ year: ts.getUTCFullYear(),
+ month: ts.getUTCMonth() + 1,
+ day: ts.getUTCDate(),
+ hour: ts.getUTCHours(),
+ minute: ts.getUTCMinutes(),
+ weekday: WEEKDAY_KEYS[ts.getUTCDay()],
+ minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
+ };
+ }
+}
+
+function parseOffsetMinutes(offsetLabel: string | null) {
+ if (!offsetLabel) return null;
+ const normalized = offsetLabel.replace("UTC", "GMT");
+ const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
+ if (!match) return null;
+ const sign = match[1] === "-" ? -1 : 1;
+ const hour = Number(match[2]);
+ const minute = Number(match[3] ?? "0");
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
+ return sign * (hour * 60 + minute);
+}
+
+function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
+ try {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ timeZoneName: "shortOffset",
+ hour: "2-digit",
+ }).formatToParts(utcDate);
+ const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
+ return parseOffsetMinutes(offsetPart);
+ } catch {
+ return null;
+ }
+}
+
+function zonedToUtcDate(input: {
+ year: number;
+ month: number;
+ day: number;
+ hours: number;
+ minutes: number;
+ timeZone: string;
+}) {
+ const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
+ const guessDate = new Date(baseUtc);
+ const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
+ if (offsetA == null) return guessDate;
+
+ let corrected = new Date(baseUtc - offsetA * 60000);
+ const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
+ if (offsetB != null && offsetB !== offsetA) {
+ corrected = new Date(baseUtc - offsetB * 60000);
+ }
+
+ return corrected;
+}
+
+function addDays(input: { year: number; month: number; day: number }, days: number) {
+ const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
+ base.setUTCDate(base.getUTCDate() + days);
+ return {
+ year: base.getUTCFullYear(),
+ month: base.getUTCMonth() + 1,
+ day: base.getUTCDate(),
+ };
+}
+
+// Detect active episodes (macrostop, mold-change) from event rows.
+// Returns the latest non-auto-ack episode whose final status is "active"
+// and that's been refreshed within ACTIVE_STALE_MS.
+const ACTIVE_STALE_MS = 2 * 60 * 1000;
+
+type ActiveEpisode = { startedAtMs: number; lastTsMs: number };
+
+function detectActiveEpisode(
+ events: TimelineEventRow[] | undefined,
+ eventType: "macrostop" | "mold-change",
+ endMs: number
+): ActiveEpisode | null {
+ if (!events || events.length === 0) return null;
+
+ type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null };
+ const episodes = new Map();
+
+ for (const event of events) {
+ if (String(event.eventType || "").toLowerCase() !== eventType) continue;
+
+ let parsed: unknown = event.data;
+ if (typeof parsed === "string") {
+ try { parsed = JSON.parse(parsed); } catch { parsed = null; }
+ }
+ const data: Record =
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : {};
+
+ const isAutoAck =
+ data.is_auto_ack === true ||
+ data.isAutoAck === true ||
+ data.is_auto_ack === "true" ||
+ data.isAutoAck === "true";
+ if (isAutoAck) continue;
+
+ const status = String(data.status ?? "").trim().toLowerCase();
+ const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
+ || `${eventType}:${event.ts.getTime()}`;
+ const tsMs = event.ts.getTime();
+ const lastCycleTs = Number(data.last_cycle_timestamp);
+
+ const existing = episodes.get(incidentKey);
+ if (!existing) {
+ episodes.set(incidentKey, {
+ firstTsMs: tsMs,
+ lastTsMs: tsMs,
+ lastStatus: status,
+ lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null,
+ });
+ continue;
+ }
+ existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
+ if (tsMs >= existing.lastTsMs) {
+ existing.lastTsMs = tsMs;
+ existing.lastStatus = status;
+ }
+ }
+
+ let best: ActiveEpisode | null = null;
+ for (const ep of episodes.values()) {
+ if (ep.lastStatus !== "active") continue;
+ if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue;
+ // Prefer the freshest active episode (highest lastTsMs)
+ if (!best || ep.lastTsMs > best.lastTsMs) {
+ best = {
+ startedAtMs: ep.lastCycleTs ?? ep.firstTsMs,
+ lastTsMs: ep.lastTsMs,
+ };
+ }
+ }
+ return best;
+}
+
+function statusFromMachine(
+ machine: RecapMachine,
+ endMs: number,
+ events?: TimelineEventRow[]
+): {
+ status: RecapMachineStatus;
+ result: MachineStateResult;
+ lastSeenMs: number | null;
+ offlineForMin: number | null;
+ ongoingStopMin: number | null;
+} {
+ const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
+ const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
+ const heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS;
+
+ const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs);
+ const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs);
+
+ // Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries
+ // we don't yet plumb here. We approximate from the legacy fields:
+ // - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on)
+ // OR when an active WO exists and machine.workOrders.moldChangeInProgress is false.
+ // This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read.
+ // - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI)
+ //
+ // Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking
+ // is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet.
+ // IDLE fires correctly when there's no WO and no recent activity.
+ const hasActiveWorkOrder = machine.workOrders.active != null;
+ const trackingEnabledApprox = hasActiveWorkOrder; // see comment above
+
+ const lastCycleTsMs = (() => {
+ // Best-effort: use the machine's heartbeat as a "recent activity" proxy.
+ // The Pi only heartbeats every minute regardless of cycles, so this is a weak signal.
+ // Round 3 will pass the actual latest cycle ts.
+ return lastSeenMs;
+ })();
+
+ const result = classifyMachineState(
+ {
+ heartbeatAlive,
+ lastSeenMs,
+ offlineForMs,
+ trackingEnabled: trackingEnabledApprox,
+ hasActiveWorkOrder,
+ activeMoldChange,
+ activeMacrostop,
+ untrackedCycles: { count: 0, oldestTsMs: null },
+ lastCycleTsMs,
+ },
+ endMs
+ );
+
+ // Map the rich classifier result back to the existing RecapMachineStatus union
+ const status: RecapMachineStatus = result.state;
+
+ // Pull common fields out for the caller's convenience
+ let ongoingStopMin: number | null = null;
+ if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin;
+
+ return {
+ status,
+ result,
+ lastSeenMs,
+ offlineForMin: result.state === "offline" ? result.offlineForMin : null,
+ ongoingStopMin,
+ };
+}
+
+async function loadTimelineRowsForMachines(params: {
+ orgId: string;
+ machineIds: string[];
+ start: Date;
+ end: Date;
+}) {
+ if (!params.machineIds.length) {
+ return {
+ cyclesByMachine: new Map(),
+ eventsByMachine: new Map(),
+ };
+ }
+
+ const [cycles, events] = await Promise.all([
+ prisma.machineCycle.findMany({
+ where: {
+ orgId: params.orgId,
+ machineId: { in: params.machineIds },
+ ts: {
+ gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
+ lte: params.end,
+ },
+ },
+ orderBy: [{ machineId: "asc" }, { ts: "asc" }],
+ select: {
+ machineId: true,
+ ts: true,
+ cycleCount: true,
+ actualCycleTime: true,
+ workOrderId: true,
+ sku: true,
+ },
+ }),
+ prisma.machineEvent.findMany({
+ where: {
+ orgId: params.orgId,
+ machineId: { in: params.machineIds },
+ eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
+ ts: {
+ gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
+ lte: params.end,
+ },
+ },
+ orderBy: [{ machineId: "asc" }, { ts: "asc" }],
+ select: {
+ machineId: true,
+ ts: true,
+ eventType: true,
+ data: true,
+ },
+ }),
+ ]);
+
+ const cyclesByMachine = new Map();
+ const eventsByMachine = new Map();
+
+ for (const row of cycles) {
+ const list = cyclesByMachine.get(row.machineId) ?? [];
+ list.push({
+ ts: row.ts,
+ cycleCount: row.cycleCount,
+ actualCycleTime: row.actualCycleTime,
+ workOrderId: row.workOrderId,
+ sku: row.sku,
+ });
+ cyclesByMachine.set(row.machineId, list);
+ }
+
+ for (const row of events) {
+ const list = eventsByMachine.get(row.machineId) ?? [];
+ list.push({
+ ts: row.ts,
+ eventType: row.eventType,
+ data: row.data,
+ });
+ eventsByMachine.set(row.machineId, list);
+ }
+
+ return { cyclesByMachine, eventsByMachine };
+}
+
+function toSummaryMachine(params: {
+ machine: RecapMachine;
+ miniTimeline: ReturnType;
+ rangeEndMs: number;
+ events?: TimelineEventRow[];
+}): RecapSummaryMachine {
+ const { machine, miniTimeline, rangeEndMs, events } = params;
+ const status = statusFromMachine(machine, rangeEndMs, events);
+
+ return {
+ machineId: machine.machineId,
+ name: machine.machineName,
+ location: machine.location,
+ status: status.status,
+ oee: machine.oee.avg,
+ goodParts: machine.production.goodParts,
+ scrap: machine.production.scrapParts,
+ stopsCount: machine.downtime.stopsCount,
+ lastSeenMs: status.lastSeenMs,
+ lastActivityMin:
+ status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
+ offlineForMin: status.offlineForMin,
+ ongoingStopMin: status.ongoingStopMin,
+ activeWorkOrderId: machine.workOrders.active?.id ?? null,
+ moldChange: {
+ active: machine.workOrders.moldChangeInProgress,
+ startMs: machine.workOrders.moldChangeStartMs,
+ elapsedMin:
+ machine.workOrders.moldChangeStartMs == null
+ ? null
+ : Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
+ },
+ miniTimeline,
+ };
+}
+
+async function computeRecapSummary(params: { orgId: string; hours: number }) {
+ const now = new Date();
+ const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
+ const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);
+
+ const recap = await getRecapDataCached({
+ orgId: params.orgId,
+ start,
+ end,
+ });
+
+ const machineIds = recap.machines.map((machine) => machine.machineId);
+ const timelineRows = await loadTimelineRowsForMachines({
+ orgId: params.orgId,
+ machineIds,
+ start,
+ end,
+ });
+
+ const machines = recap.machines.map((machine) => {
+ const segments = buildTimelineSegments({
+ cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
+ events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
+ rangeStart: start,
+ rangeEnd: end,
+ });
+ const miniTimeline = compressTimelineSegments({
+ segments,
+ rangeStart: start,
+ rangeEnd: end,
+ maxSegments: 60,
+ });
+
+ return toSummaryMachine({
+ machine,
+ miniTimeline,
+ rangeEndMs: end.getTime(),
+ events: timelineRows.eventsByMachine.get(machine.machineId),
+ });
+ });
+
+ const response: RecapSummaryResponse = {
+ generatedAt: new Date().toISOString(),
+ range: {
+ start: start.toISOString(),
+ end: end.toISOString(),
+ hours: params.hours,
+ },
+ machines,
+ };
+
+ return response;
+}
+
+function normalizedRangeMode(mode?: string | null): RecapRangeMode {
+ const raw = String(mode ?? "").trim().toLowerCase();
+ if (raw === "shift") return "shift";
+ if (raw === "yesterday") return "yesterday";
+ if (raw === "custom") return "custom";
+ return "24h";
+}
+
+async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
+ const settings = await prisma.orgSettings.findUnique({
+ where: { orgId: params.orgId },
+ select: {
+ timezone: true,
+ shiftScheduleOverridesJson: true,
+ },
+ });
+ const shifts = await prisma.orgShift.findMany({
+ where: { orgId: params.orgId },
+ orderBy: { sortOrder: "asc" },
+ select: {
+ name: true,
+ startTime: true,
+ endTime: true,
+ enabled: true,
+ sortOrder: true,
+ },
+ });
+
+ const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
+ if (!enabledShifts.length) {
+ return {
+ hasEnabledShifts: false,
+ range: null,
+ } as const;
+ }
+
+ const timeZone = settings?.timezone || "UTC";
+ const local = getLocalParts(params.now, timeZone);
+ const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
+ const dayOverrides = overrides?.[local.weekday];
+ const activeShifts = (dayOverrides?.length
+ ? dayOverrides.map((shift) => ({
+ enabled: shift.enabled !== false,
+ start: shift.start,
+ end: shift.end,
+ }))
+ : enabledShifts.map((shift) => ({
+ enabled: shift.enabled !== false,
+ start: shift.startTime,
+ end: shift.endTime,
+ }))
+ ).filter((shift) => shift.enabled);
+
+ for (const shift of activeShifts) {
+ const startMin = parseTimeMinutes(shift.start ?? null);
+ const endMin = parseTimeMinutes(shift.end ?? null);
+ if (startMin == null || endMin == null) continue;
+
+ const minutesNow = local.minutesOfDay;
+ let inRange = false;
+ let startDate = { year: local.year, month: local.month, day: local.day };
+ let endDate = { year: local.year, month: local.month, day: local.day };
+
+ if (startMin <= endMin) {
+ inRange = minutesNow >= startMin && minutesNow < endMin;
+ } else {
+ inRange = minutesNow >= startMin || minutesNow < endMin;
+ if (minutesNow >= startMin) {
+ endDate = addDays(endDate, 1);
+ } else {
+ startDate = addDays(startDate, -1);
+ }
+ }
+
+ if (!inRange) continue;
+
+ const start = zonedToUtcDate({
+ ...startDate,
+ hours: Math.floor(startMin / 60),
+ minutes: startMin % 60,
+ timeZone,
+ });
+ const shiftEndUtc = zonedToUtcDate({
+ ...endDate,
+ hours: Math.floor(endMin / 60),
+ minutes: endMin % 60,
+ timeZone,
+ });
+
+ if (shiftEndUtc <= start) continue;
+
+ // Cap end at "now" so we render shift-so-far, not shift-as-planned.
+ // Without cap:
+ // - timeline fills future minutes with idle (visual lie)
+ // - offline calc = (shift_end_future - last_seen) = looks 5h offline
+ // even on a machine producing right now
+ const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
+
+ return {
+ hasEnabledShifts: true,
+ range: { start, end },
+ };
+ }
+
+ return {
+ hasEnabledShifts: true,
+ range: null,
+ } as const;
+}
+
+async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
+ const now = new Date(Math.floor(Date.now() / 60000) * 60000);
+ const requestedMode = normalizedRangeMode(params.input.mode);
+ const shiftEnabledCount = await prisma.orgShift.count({
+ where: {
+ orgId: params.orgId,
+ enabled: { not: false },
+ },
+ });
+ const shiftAvailable = shiftEnabledCount > 0;
+
+ if (requestedMode === "custom") {
+ const start = parseDate(params.input.start);
+ const end = parseDate(params.input.end);
+ if (start && end && end > start) {
+ return {
+ requestedMode,
+ mode: requestedMode,
+ start,
+ end,
+ shiftAvailable,
+ } as const;
+ }
+ }
+
+ if (requestedMode === "yesterday") {
+ const settings = await prisma.orgSettings.findUnique({
+ where: { orgId: params.orgId },
+ select: { timezone: true },
+ });
+ const timeZone = settings?.timezone || "America/Mexico_City";
+ const localNow = getLocalParts(now, timeZone);
+ const today = { year: localNow.year, month: localNow.month, day: localNow.day };
+ const yesterday = addDays(today, -1);
+ const start = zonedToUtcDate({
+ ...yesterday,
+ hours: 0,
+ minutes: 0,
+ timeZone,
+ });
+ const end = zonedToUtcDate({
+ ...today,
+ hours: 0,
+ minutes: 0,
+ timeZone,
+ });
+ return {
+ requestedMode,
+ mode: requestedMode,
+ start,
+ end,
+ shiftAvailable,
+ } as const;
+ }
+
+ if (requestedMode === "shift") {
+ const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
+ if (shiftRange.range) {
+ return {
+ requestedMode,
+ mode: requestedMode,
+ start: shiftRange.range.start,
+ end: shiftRange.range.end,
+ shiftAvailable,
+ } as const;
+ }
+ if (!shiftRange.hasEnabledShifts) {
+ return {
+ requestedMode,
+ mode: "24h" as const,
+ start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
+ end: now,
+ shiftAvailable,
+ fallbackReason: "shift-unavailable" as const,
+ } as const;
+ }
+ return {
+ requestedMode,
+ mode: "24h" as const,
+ start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
+ end: now,
+ shiftAvailable,
+ fallbackReason: "shift-inactive" as const,
+ } as const;
+ }
+
+ return {
+ requestedMode,
+ mode: "24h" as const,
+ start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
+ end: now,
+ shiftAvailable,
+ } as const;
+}
+
+async function computeRecapMachineDetail(params: {
+ orgId: string;
+ machineId: string;
+ range: {
+ requestedMode: RecapRangeMode;
+ mode: RecapRangeMode;
+ start: Date;
+ end: Date;
+ shiftAvailable: boolean;
+ fallbackReason?: "shift-unavailable" | "shift-inactive";
+ };
+}) {
+ const { range } = params;
+
+ const recap = await getRecapDataCached({
+ orgId: params.orgId,
+ machineId: params.machineId,
+ start: range.start,
+ end: range.end,
+ });
+
+ const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null;
+ if (!machine) return null;
+
+ const timelineRows = await loadTimelineRowsForMachines({
+ orgId: params.orgId,
+ machineIds: [params.machineId],
+ start: range.start,
+ end: range.end,
+ });
+
+ const timeline = buildTimelineSegments({
+ cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [],
+ events: timelineRows.eventsByMachine.get(params.machineId) ?? [],
+ rangeStart: range.start,
+ rangeEnd: range.end,
+ });
+
+ const status = statusFromMachine(
+ machine,
+ range.end.getTime(),
+ timelineRows.eventsByMachine.get(params.machineId)
+ );
+
+ const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
+ const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
+ reasonLabel: row.reasonLabel,
+ minutes: row.minutes,
+ count: row.count,
+ percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0,
+ }));
+
+ const machineDetail: RecapMachineDetail = {
+ machineId: machine.machineId,
+ name: machine.machineName,
+ location: machine.location,
+ status: status.status,
+ oee: machine.oee.avg,
+ goodParts: machine.production.goodParts,
+ scrap: machine.production.scrapParts,
+ stopsCount: machine.downtime.stopsCount,
+ stopMinutes: downtimeTotalMin,
+ activeWorkOrderId: machine.workOrders.active?.id ?? null,
+ lastSeenMs: status.lastSeenMs,
+ offlineForMin: status.offlineForMin,
+ ongoingStopMin: status.ongoingStopMin,
+ moldChange: {
+ active: machine.workOrders.moldChangeInProgress,
+ startMs: machine.workOrders.moldChangeStartMs,
+ },
+ timeline,
+ productionBySku: machine.production.bySku,
+ downtimeTop,
+ workOrders: {
+ completed: machine.workOrders.completed,
+ active: machine.workOrders.active,
+ },
+ heartbeat: {
+ lastSeenAt: machine.heartbeat.lastSeenAt,
+ uptimePct: machine.heartbeat.uptimePct,
+ connectionStatus: status.status === "offline" ? "offline" : "online",
+ },
+ };
+
+ const response: RecapDetailResponse = {
+ generatedAt: new Date().toISOString(),
+ range: {
+ requestedMode: range.requestedMode,
+ mode: range.mode,
+ start: range.start.toISOString(),
+ end: range.end.toISOString(),
+ shiftAvailable: range.shiftAvailable,
+ fallbackReason: range.fallbackReason,
+ },
+ machine: machineDetail,
+ };
+
+ return response;
+}
+
+function summaryCacheKey(params: { orgId: string; hours: number }) {
+ return ["recap-summary-v1", params.orgId, String(params.hours)];
+}
+
+function detailCacheKey(params: {
+ orgId: string;
+ machineId: string;
+ requestedMode: RecapRangeMode;
+ mode: RecapRangeMode;
+ shiftAvailable: boolean;
+ fallbackReason?: "shift-unavailable" | "shift-inactive";
+ startMs: number;
+ endMs: number;
+}) {
+ return [
+ "recap-detail-v1",
+ params.orgId,
+ params.machineId,
+ params.requestedMode,
+ params.mode,
+ params.shiftAvailable ? "shift-on" : "shift-off",
+ params.fallbackReason ?? "",
+ String(Math.trunc(params.startMs / 60000)),
+ String(Math.trunc(params.endMs / 60000)),
+ ];
+}
+
+export function parseRecapSummaryHours(raw: string | null) {
+ return parseHours(raw);
+}
+
+export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record) {
+ if (searchParams instanceof URLSearchParams) {
+ return {
+ mode: searchParams.get("range") ?? undefined,
+ start: searchParams.get("start") ?? undefined,
+ end: searchParams.get("end") ?? undefined,
+ };
+ }
+
+ const pick = (key: string) => {
+ const value = searchParams[key];
+ if (Array.isArray(value)) return value[0] ?? undefined;
+ return value ?? undefined;
+ };
+
+ return {
+ mode: pick("range"),
+ start: pick("start"),
+ end: pick("end"),
+ };
+}
+
+export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
+ const cache = unstable_cache(
+ () => computeRecapSummary(params),
+ summaryCacheKey(params),
+ {
+ revalidate: RECAP_CACHE_TTL_SEC,
+ tags: [`recap:${params.orgId}`],
+ }
+ );
+
+ return cache();
+}
+
+export async function getRecapMachineDetailCached(params: {
+ orgId: string;
+ machineId: string;
+ input: DetailRangeInput;
+}) {
+ const resolved = await resolveDetailRange({
+ orgId: params.orgId,
+ input: params.input,
+ });
+
+ const cache = unstable_cache(
+ () =>
+ computeRecapMachineDetail({
+ orgId: params.orgId,
+ machineId: params.machineId,
+ range: {
+ requestedMode: resolved.requestedMode,
+ mode: resolved.mode,
+ start: resolved.start,
+ end: resolved.end,
+ shiftAvailable: resolved.shiftAvailable,
+ fallbackReason: resolved.fallbackReason,
+ },
+ }),
+ detailCacheKey({
+ orgId: params.orgId,
+ machineId: params.machineId,
+ requestedMode: resolved.requestedMode,
+ mode: resolved.mode,
+ shiftAvailable: resolved.shiftAvailable,
+ fallbackReason: resolved.fallbackReason,
+ startMs: resolved.start.getTime(),
+ endMs: resolved.end.getTime(),
+ }),
+ {
+ revalidate: RECAP_CACHE_TTL_SEC,
+ tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
+ }
+ );
+
+ return cache();
+}
diff --git a/lib/recap/types.ts b/lib/recap/types.ts
index 1828ec3..e65f608 100644
--- a/lib/recap/types.ts
+++ b/lib/recap/types.ts
@@ -121,7 +121,24 @@ export type RecapQuery = {
shift?: string;
};
-export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline";
+export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "data-loss" | "offline" | "idle";
+
+export type RecapStoppedReason = "machine_fault" | "not_started";
+export type RecapDataLossReason = "untracked";
+
+/**
+ * Reason context for STOPPED and DATA_LOSS states.
+ * - When status is "stopped": stoppedReason is set, dataLossReason is null.
+ * - When status is "data-loss": dataLossReason is set, stoppedReason is null.
+ * - All other states: both are null.
+ */
+export type RecapStateContext = {
+ stoppedReason: RecapStoppedReason | null;
+ dataLossReason: RecapDataLossReason | null;
+ /** For data-loss: how many untracked cycles have been detected so far. */
+ untrackedCycleCount: number | null;
+};
+
export type RecapSummaryMachine = {
machineId: string;
@@ -136,6 +153,7 @@ export type RecapSummaryMachine = {
lastActivityMin: number | null;
offlineForMin: number | null;
ongoingStopMin: number | null;
+ stateContext: RecapStateContext;
activeWorkOrderId: string | null;
moldChange: {
active: boolean;
@@ -193,6 +211,7 @@ export type RecapMachineDetail = {
lastSeenMs: number | null;
offlineForMin: number | null;
ongoingStopMin: number | null;
+ stateContext: RecapStateContext;
moldChange: {
active: boolean;
startMs: number | null;
diff --git a/lib/recap/types.ts.bak b/lib/recap/types.ts.bak
new file mode 100644
index 0000000..1828ec3
--- /dev/null
+++ b/lib/recap/types.ts.bak
@@ -0,0 +1,222 @@
+export type RecapSkuRow = {
+ machineName: string;
+ sku: string;
+ good: number;
+ scrap: number;
+ target: number | null;
+ progressPct: number | null;
+};
+
+export type RecapMachine = {
+ machineId: string;
+ machineName: string;
+ location: string | null;
+ production: {
+ goodParts: number;
+ scrapParts: number;
+ totalCycles: number;
+ bySku: RecapSkuRow[];
+ };
+ oee: {
+ avg: number | null;
+ availability: number | null;
+ performance: number | null;
+ quality: number | null;
+ };
+ downtime: {
+ totalMin: number;
+ stopsCount: number;
+ topReasons: Array<{
+ reasonLabel: string;
+ minutes: number;
+ count: number;
+ }>;
+ ongoingStopMin: number | null;
+ };
+ workOrders: {
+ completed: Array<{
+ id: string;
+ sku: string | null;
+ goodParts: number;
+ durationHrs: number;
+ }>;
+ active: {
+ id: string;
+ sku: string | null;
+ progressPct: number | null;
+ startedAt: string | null;
+ } | null;
+ moldChangeInProgress: boolean;
+ moldChangeStartMs: number | null;
+ };
+ heartbeat: {
+ lastSeenAt: string | null;
+ uptimePct: number | null;
+ };
+};
+
+export type RecapTimelineSegment =
+ | {
+ type: "production";
+ startMs: number;
+ endMs: number;
+ durationSec: number;
+ workOrderId: string | null;
+ sku: string | null;
+ label: string;
+ }
+ | {
+ type: "mold-change";
+ startMs: number;
+ endMs: number;
+ fromMoldId: string | null;
+ toMoldId: string | null;
+ durationSec: number;
+ label: string;
+ }
+ | {
+ type: "macrostop" | "microstop" | "slow-cycle";
+ startMs: number;
+ endMs: number;
+ reason: string | null;
+ reasonLabel?: string | null;
+ durationSec: number;
+ label: string;
+ }
+ | {
+ type: "idle";
+ startMs: number;
+ endMs: number;
+ durationSec: number;
+ label: string;
+ };
+
+export type RecapTimelineResponse = {
+ range: {
+ start: string;
+ end: string;
+ };
+ segments: RecapTimelineSegment[];
+ hasData: boolean;
+ generatedAt: string;
+};
+
+export type RecapResponse = {
+ range: {
+ start: string;
+ end: string;
+ };
+ availableShifts: Array<{
+ id: string;
+ name: string;
+ }>;
+ machines: RecapMachine[];
+};
+
+export type RecapQuery = {
+ orgId: string;
+ machineId?: string;
+ start?: Date;
+ end?: Date;
+ shift?: string;
+};
+
+export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline";
+
+export type RecapSummaryMachine = {
+ machineId: string;
+ name: string;
+ location: string | null;
+ status: RecapMachineStatus;
+ oee: number | null;
+ goodParts: number;
+ scrap: number;
+ stopsCount: number;
+ lastSeenMs: number | null;
+ lastActivityMin: number | null;
+ offlineForMin: number | null;
+ ongoingStopMin: number | null;
+ activeWorkOrderId: string | null;
+ moldChange: {
+ active: boolean;
+ startMs: number | null;
+ elapsedMin: number | null;
+ } | null;
+ miniTimeline: RecapTimelineSegment[];
+};
+
+export type RecapSummaryResponse = {
+ generatedAt: string;
+ range: {
+ start: string;
+ end: string;
+ hours: number;
+ };
+ machines: RecapSummaryMachine[];
+};
+
+export type RecapRangeMode = "24h" | "shift" | "yesterday" | "custom";
+
+export type RecapDowntimeTopRow = {
+ reasonLabel: string;
+ minutes: number;
+ count: number;
+ percent: number;
+};
+
+export type RecapWorkOrders = {
+ completed: Array<{
+ id: string;
+ sku: string | null;
+ goodParts: number;
+ durationHrs: number;
+ }>;
+ active: {
+ id: string;
+ sku: string | null;
+ progressPct: number | null;
+ startedAt: string | null;
+ } | null;
+};
+
+export type RecapMachineDetail = {
+ machineId: string;
+ name: string;
+ location: string | null;
+ status: RecapMachineStatus;
+ oee: number | null;
+ goodParts: number;
+ scrap: number;
+ stopsCount: number;
+ stopMinutes: number;
+ activeWorkOrderId: string | null;
+ lastSeenMs: number | null;
+ offlineForMin: number | null;
+ ongoingStopMin: number | null;
+ moldChange: {
+ active: boolean;
+ startMs: number | null;
+ } | null;
+ timeline: RecapTimelineSegment[];
+ productionBySku: RecapSkuRow[];
+ downtimeTop: RecapDowntimeTopRow[];
+ workOrders: RecapWorkOrders;
+ heartbeat: {
+ lastSeenAt: string | null;
+ uptimePct: number | null;
+ connectionStatus: "online" | "offline";
+ };
+};
+
+export type RecapDetailResponse = {
+ generatedAt: string;
+ range: {
+ requestedMode?: RecapRangeMode;
+ mode: RecapRangeMode;
+ start: string;
+ end: string;
+ shiftAvailable?: boolean;
+ fallbackReason?: "shift-unavailable" | "shift-inactive";
+ };
+ machine: RecapMachineDetail;
+};
diff --git a/lib/recap/types.ts.bak.step4 b/lib/recap/types.ts.bak.step4
new file mode 100644
index 0000000..f3b4037
--- /dev/null
+++ b/lib/recap/types.ts.bak.step4
@@ -0,0 +1,222 @@
+export type RecapSkuRow = {
+ machineName: string;
+ sku: string;
+ good: number;
+ scrap: number;
+ target: number | null;
+ progressPct: number | null;
+};
+
+export type RecapMachine = {
+ machineId: string;
+ machineName: string;
+ location: string | null;
+ production: {
+ goodParts: number;
+ scrapParts: number;
+ totalCycles: number;
+ bySku: RecapSkuRow[];
+ };
+ oee: {
+ avg: number | null;
+ availability: number | null;
+ performance: number | null;
+ quality: number | null;
+ };
+ downtime: {
+ totalMin: number;
+ stopsCount: number;
+ topReasons: Array<{
+ reasonLabel: string;
+ minutes: number;
+ count: number;
+ }>;
+ ongoingStopMin: number | null;
+ };
+ workOrders: {
+ completed: Array<{
+ id: string;
+ sku: string | null;
+ goodParts: number;
+ durationHrs: number;
+ }>;
+ active: {
+ id: string;
+ sku: string | null;
+ progressPct: number | null;
+ startedAt: string | null;
+ } | null;
+ moldChangeInProgress: boolean;
+ moldChangeStartMs: number | null;
+ };
+ heartbeat: {
+ lastSeenAt: string | null;
+ uptimePct: number | null;
+ };
+};
+
+export type RecapTimelineSegment =
+ | {
+ type: "production";
+ startMs: number;
+ endMs: number;
+ durationSec: number;
+ workOrderId: string | null;
+ sku: string | null;
+ label: string;
+ }
+ | {
+ type: "mold-change";
+ startMs: number;
+ endMs: number;
+ fromMoldId: string | null;
+ toMoldId: string | null;
+ durationSec: number;
+ label: string;
+ }
+ | {
+ type: "macrostop" | "microstop" | "slow-cycle";
+ startMs: number;
+ endMs: number;
+ reason: string | null;
+ reasonLabel?: string | null;
+ durationSec: number;
+ label: string;
+ }
+ | {
+ type: "idle";
+ startMs: number;
+ endMs: number;
+ durationSec: number;
+ label: string;
+ };
+
+export type RecapTimelineResponse = {
+ range: {
+ start: string;
+ end: string;
+ };
+ segments: RecapTimelineSegment[];
+ hasData: boolean;
+ generatedAt: string;
+};
+
+export type RecapResponse = {
+ range: {
+ start: string;
+ end: string;
+ };
+ availableShifts: Array<{
+ id: string;
+ name: string;
+ }>;
+ machines: RecapMachine[];
+};
+
+export type RecapQuery = {
+ orgId: string;
+ machineId?: string;
+ start?: Date;
+ end?: Date;
+ shift?: string;
+};
+
+export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "data-loss" | "offline" | "idle";
+
+export type RecapSummaryMachine = {
+ machineId: string;
+ name: string;
+ location: string | null;
+ status: RecapMachineStatus;
+ oee: number | null;
+ goodParts: number;
+ scrap: number;
+ stopsCount: number;
+ lastSeenMs: number | null;
+ lastActivityMin: number | null;
+ offlineForMin: number | null;
+ ongoingStopMin: number | null;
+ activeWorkOrderId: string | null;
+ moldChange: {
+ active: boolean;
+ startMs: number | null;
+ elapsedMin: number | null;
+ } | null;
+ miniTimeline: RecapTimelineSegment[];
+};
+
+export type RecapSummaryResponse = {
+ generatedAt: string;
+ range: {
+ start: string;
+ end: string;
+ hours: number;
+ };
+ machines: RecapSummaryMachine[];
+};
+
+export type RecapRangeMode = "24h" | "shift" | "yesterday" | "custom";
+
+export type RecapDowntimeTopRow = {
+ reasonLabel: string;
+ minutes: number;
+ count: number;
+ percent: number;
+};
+
+export type RecapWorkOrders = {
+ completed: Array<{
+ id: string;
+ sku: string | null;
+ goodParts: number;
+ durationHrs: number;
+ }>;
+ active: {
+ id: string;
+ sku: string | null;
+ progressPct: number | null;
+ startedAt: string | null;
+ } | null;
+};
+
+export type RecapMachineDetail = {
+ machineId: string;
+ name: string;
+ location: string | null;
+ status: RecapMachineStatus;
+ oee: number | null;
+ goodParts: number;
+ scrap: number;
+ stopsCount: number;
+ stopMinutes: number;
+ activeWorkOrderId: string | null;
+ lastSeenMs: number | null;
+ offlineForMin: number | null;
+ ongoingStopMin: number | null;
+ moldChange: {
+ active: boolean;
+ startMs: number | null;
+ } | null;
+ timeline: RecapTimelineSegment[];
+ productionBySku: RecapSkuRow[];
+ downtimeTop: RecapDowntimeTopRow[];
+ workOrders: RecapWorkOrders;
+ heartbeat: {
+ lastSeenAt: string | null;
+ uptimePct: number | null;
+ connectionStatus: "online" | "offline";
+ };
+};
+
+export type RecapDetailResponse = {
+ generatedAt: string;
+ range: {
+ requestedMode?: RecapRangeMode;
+ mode: RecapRangeMode;
+ start: string;
+ end: string;
+ shiftAvailable?: boolean;
+ fallbackReason?: "shift-unavailable" | "shift-inactive";
+ };
+ machine: RecapMachineDetail;
+};
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 30e38ed..2501ce3 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -135,6 +135,8 @@ model Machine {
settings MachineSettings?
workOrders MachineWorkOrder[]
settingsAudits SettingsAudit[]
+ productionEnabled Boolean @default(true) @map("production_enabled")
+
@@unique([orgId, name])
@@index([orgId])