From 0491237bad1d6384a34c53d9dde3aa6a03852e11 Mon Sep 17 00:00:00 2001 From: Marcelo Date: Sat, 2 May 2026 01:35:27 +0000 Subject: [PATCH] state definitions --- MACHINE_STATE_PROGRESS.md | 36 + app/(app)/recap/RecapGridClient.tsx | 4 +- app/(app)/recap/RecapGridClient.tsx.bak.step5 | 153 +++ components/recap/RecapFullTimeline.tsx | 13 +- components/recap/RecapMachineCard.tsx | 22 +- .../recap/RecapMachineCard.tsx.bak.step5 | 153 +++ lib/i18n/en.json | 5 + lib/i18n/es-MX.json | 5 + lib/recap/machineState.ts | 174 ++++ lib/recap/redesign.ts | 145 ++- lib/recap/redesign.ts.bak.step3 | 848 ++++++++++++++++ lib/recap/redesign.ts.bak.step4 | 905 ++++++++++++++++++ lib/recap/types.ts | 21 +- lib/recap/types.ts.bak | 222 +++++ lib/recap/types.ts.bak.step4 | 222 +++++ prisma/schema.prisma | 2 + 16 files changed, 2887 insertions(+), 43 deletions(-) create mode 100644 MACHINE_STATE_PROGRESS.md create mode 100644 app/(app)/recap/RecapGridClient.tsx.bak.step5 create mode 100644 components/recap/RecapMachineCard.tsx.bak.step5 create mode 100644 lib/recap/machineState.ts create mode 100644 lib/recap/redesign.ts.bak.step3 create mode 100644 lib/recap/redesign.ts.bak.step4 create mode 100644 lib/recap/types.ts.bak create mode 100644 lib/recap/types.ts.bak.step4 diff --git a/MACHINE_STATE_PROGRESS.md b/MACHINE_STATE_PROGRESS.md new file mode 100644 index 0000000..46adfed --- /dev/null +++ b/MACHINE_STATE_PROGRESS.md @@ -0,0 +1,36 @@ +# Machine State Progress + +## Final State Model (5 states + sub-reasons) + +| State | Color | Trigger | +|---|---|---| +| OFFLINE | dark gray | Heartbeat dead >2 min | +| STOPPED | red, pulse >5min | Active WO + no cycles (regardless of tracking) | +| - reason `machine_fault` | | Tracking on, macrostop event active | +| - reason `not_started` | | Tracking off, has WO | +| DATA_LOSS | red + icon, pulse | Tracking off + cycles arriving (>5 cycles or >10 min) | +| MOLD_CHANGE | blue | Active mold-change event | +| - sub at >3h | yellow accent | (Round 2) | +| - sub at >5h | red accent | (Round 2) | +| IDLE | calm gray | No tracking, no WO, no cycles | +| RUNNING | green | Tracking + WO + recent cycles | + +## Round 1 — Foundation: classifier + IDLE + STOPPED collapse + DATA_LOSS +- [x] Step 1: Add `"idle"` and `"data-loss"` to `RecapMachineStatus` union +- [x] Step 2: Create `lib/recap/machineState.ts` shared classifier with all reasons +- [x] Step 3: Refactor `statusFromMachine` in redesign.ts to call classifier +- [x] Step 4: Plumb new fields (status reason, ongoing min) through types/responses +- [x] Step 5: UI rendering: IDLE (calm gray) on /recap, /machines, detail +- [x] Step 6: UI rendering: DATA_LOSS (red + icon) on all surfaces +- [x] Step 7: STOPPED reason text: show `not_started` vs `machine_fault` distinction +- [x] Step 8: i18n keys (en + es-MX) +- [x] Step 9: End-to-end verify each state transitions correctly + +## Round 2 — Mold change duration escalation (CT-only) +- [ ] MOLD_CHANGE >3h yellow accent +- [ ] MOLD_CHANGE >5h red accent +- [ ] i18n strings + +## Notes / parked items +- Prisma drift on (orgId,machineId,seq) unique indexes — pre-existing, not related to this work. Address as separate housekeeping task. +- Node-RED incidentKey rotation behavior verified: 10 distinct keys per real stoppage = correct. diff --git a/app/(app)/recap/RecapGridClient.tsx b/app/(app)/recap/RecapGridClient.tsx index e44e999..3d4cdd6 100644 --- a/app/(app)/recap/RecapGridClient.tsx +++ b/app/(app)/recap/RecapGridClient.tsx @@ -13,6 +13,8 @@ function statusLabel(status: RecapMachineStatus, 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"); + if (status === "data-loss") return t("recap.status.dataLoss"); + if (status === "idle") return t("recap.status.idle"); return t("recap.status.offline"); } @@ -110,7 +112,7 @@ export default function RecapGridClient({ initialData }: Props) { className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200" > - {(["running", "mold-change", "stopped", "offline"] as const).map((status) => ( + {(["running", "mold-change", "stopped", "data-loss", "idle", "offline"] as const).map((status) => ( diff --git a/app/(app)/recap/RecapGridClient.tsx.bak.step5 b/app/(app)/recap/RecapGridClient.tsx.bak.step5 new file mode 100644 index 0000000..e44e999 --- /dev/null +++ b/app/(app)/recap/RecapGridClient.tsx.bak.step5 @@ -0,0 +1,153 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useI18n } from "@/lib/i18n/useI18n"; +import type { RecapMachineStatus, RecapSummaryResponse } from "@/lib/recap/types"; +import RecapMachineCard from "@/components/recap/RecapMachineCard"; + +type Props = { + initialData: RecapSummaryResponse; +}; + +function statusLabel(status: RecapMachineStatus, 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"); +} + +export default function RecapGridClient({ initialData }: Props) { + const { t } = useI18n(); + + const [data, setData] = useState(initialData); + const [loading, setLoading] = useState(false); + const [locationFilter, setLocationFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState<"all" | RecapMachineStatus>("all"); + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + const timer = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + let alive = true; + + async function refresh() { + setLoading(true); + try { + const res = await fetch(`/api/recap/summary?hours=${data.range.hours}`, { cache: "no-store" }); + const json = await res.json().catch(() => null); + if (!alive || !json || !res.ok) return; + setData(json as RecapSummaryResponse); + } finally { + if (alive) setLoading(false); + } + } + + const onFocus = () => { + void refresh(); + }; + + const interval = window.setInterval(onFocus, 60000); + window.addEventListener("focus", onFocus); + + return () => { + alive = false; + window.clearInterval(interval); + window.removeEventListener("focus", onFocus); + }; + }, [data.range.hours]); + + const locationOptions = useMemo(() => { + const set = new Set(); + for (const machine of data.machines) { + if (machine.location) set.add(machine.location); + } + return [...set].sort((a, b) => a.localeCompare(b)); + }, [data.machines]); + + const filteredMachines = useMemo(() => { + return data.machines.filter((machine) => { + if (locationFilter !== "all" && machine.location !== locationFilter) return false; + if (statusFilter !== "all" && machine.status !== statusFilter) return false; + return true; + }); + }, [data.machines, locationFilter, statusFilter]); + + const generatedAtMs = new Date(data.generatedAt).getTime(); + const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null; + + return ( +
+
+
+
+

{t("recap.grid.title")}

+

{t("recap.grid.subtitle")}

+ {freshAgeSec != null ? ( +

{t("recap.grid.updatedAgo", { sec: freshAgeSec })}

+ ) : null} +
+ +
+ + + +
+
+
+ + {loading && data.machines.length === 0 ? ( +
+ {Array.from({ length: 6 }).map((_, idx) => ( +
+ ))} +
+ ) : null} + + {loading && data.machines.length > 0 ? ( +
{t("common.loading")}
+ ) : null} + + {filteredMachines.length === 0 ? ( +
+ {t("recap.grid.empty")} +
+ ) : ( +
+ {filteredMachines.map((machine) => ( + + ))} +
+ )} +
+ ); +} diff --git a/components/recap/RecapFullTimeline.tsx b/components/recap/RecapFullTimeline.tsx index 1e0b795..daf4b7b 100644 --- a/components/recap/RecapFullTimeline.tsx +++ b/components/recap/RecapFullTimeline.tsx @@ -90,18 +90,19 @@ export default function RecapFullTimeline({ locale )} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`; + const showLabel = widthPct > LABEL_MIN_WIDTH_PCT; return (
- {widthPct > LABEL_MIN_WIDTH_PCT ? segment.label : ""} + {showLabel ? segment.label : ""}
); })} diff --git a/components/recap/RecapMachineCard.tsx b/components/recap/RecapMachineCard.tsx index 7a56018..c3df147 100644 --- a/components/recap/RecapMachineCard.tsx +++ b/components/recap/RecapMachineCard.tsx @@ -16,13 +16,17 @@ const STATUS_DOT: Record = { running: "bg-emerald-400", "mold-change": "bg-amber-400", stopped: "bg-red-500", + "data-loss": "bg-red-500", offline: "bg-zinc-500", + idle: "bg-zinc-400", }; 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"); + if (status === "data-loss") return t("recap.status.dataLoss"); + if (status === "idle") return t("recap.status.idle"); return t("recap.status.offline"); } @@ -38,7 +42,8 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop 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 isUrgent = (machine.status === "stopped" && ongoingStopMin >= 5) || machine.status === "data-loss"; + const isCalm = machine.status === "idle"; const timelineSegments = timeline?.segments ?? machine.miniTimeline; const timelineStart = timeline?.range.start ?? rangeStart; const timelineEnd = timeline?.range.end ?? rangeEnd; @@ -88,6 +93,8 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop className={`rounded-2xl border p-4 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80 ${ isUrgent ? "border-red-500/60 bg-red-500/10 hover:bg-red-500/15 ring-2 ring-red-500/40 animate-pulse" + : isCalm + ? "border-white/5 bg-white/[0.02] hover:bg-white/[0.04] opacity-70" : "border-white/10 bg-white/5 hover:bg-white/10" }`} > @@ -142,10 +149,17 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
) : null} -
- {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])