From 864be8d93283048f9ab0d8d18331eee4c1f0f93a Mon Sep 17 00:00:00 2001 From: Marcelo Date: Fri, 1 May 2026 06:02:12 +0000 Subject: [PATCH] almost final --- app/(app)/machines/MachinesClient.tsx.bak | 4 +- .../[machineId]/RecapDetailClient.tsx.bak | 240 ++++++ app/api/machines/route.ts.bak | 161 ++++ components/recap/RecapMachineCard.tsx | 15 +- components/recap/RecapMachineCard.tsx.bak | 142 ++++ lib/i18n/en.json | 1 + lib/i18n/es-MX.json | 1 + lib/machines/withLatest.ts.bak | 113 +++ lib/recap/redesign.ts | 86 +- lib/recap/redesign.ts.bak | 776 ++++++++++++++++++ lib/recap/timeline.ts | 36 +- 11 files changed, 1550 insertions(+), 25 deletions(-) create mode 100644 app/(app)/recap/[machineId]/RecapDetailClient.tsx.bak create mode 100644 app/api/machines/route.ts.bak create mode 100644 components/recap/RecapMachineCard.tsx.bak create mode 100644 lib/machines/withLatest.ts.bak create mode 100644 lib/recap/redesign.ts.bak diff --git a/app/(app)/machines/MachinesClient.tsx.bak b/app/(app)/machines/MachinesClient.tsx.bak index 4ea2c7e..2576063 100644 --- a/app/(app)/machines/MachinesClient.tsx.bak +++ b/app/(app)/machines/MachinesClient.tsx.bak @@ -4,6 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState, type KeyboardEvent } from "react"; import { useI18n } from "@/lib/i18n/useI18n"; +import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants"; type MachineRow = { id: string; @@ -20,6 +21,7 @@ type MachineRow = { }; }; const LIVE_REFRESH_MS = 5000; +const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS; function secondsAgo(ts: string | undefined, locale: string, fallback: string) { if (!ts) return fallback; @@ -31,7 +33,7 @@ function secondsAgo(ts: string | undefined, locale: string, fallback: string) { function isOffline(ts?: string) { if (!ts) return true; - return Date.now() - new Date(ts).getTime() > 30000; // 30s threshold + return Date.now() - new Date(ts).getTime() > OFFLINE_MS; } function normalizeStatus(status?: string) { diff --git a/app/(app)/recap/[machineId]/RecapDetailClient.tsx.bak b/app/(app)/recap/[machineId]/RecapDetailClient.tsx.bak new file mode 100644 index 0000000..bd2279d --- /dev/null +++ b/app/(app)/recap/[machineId]/RecapDetailClient.tsx.bak @@ -0,0 +1,240 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState, useTransition } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useI18n } from "@/lib/i18n/useI18n"; +import type { RecapDetailResponse, RecapRangeMode, RecapTimelineResponse } from "@/lib/recap/types"; +import RecapBanners from "@/components/recap/RecapBanners"; +import RecapKpiRow from "@/components/recap/RecapKpiRow"; +import RecapProductionBySku from "@/components/recap/RecapProductionBySku"; +import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop"; +import RecapWorkOrders from "@/components/recap/RecapWorkOrders"; +import RecapMachineStatus from "@/components/recap/RecapMachineStatus"; +import RecapFullTimeline from "@/components/recap/RecapFullTimeline"; + +type Props = { + machineId: string; + initialData: RecapDetailResponse; +}; + +function toInputDate(value: string) { + const d = new Date(value); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function normalizeInputDate(value: string) { + const d = new Date(value); + if (!Number.isFinite(d.getTime())) return null; + return d.toISOString(); +} + +export default function RecapDetailClient({ machineId, initialData }: Props) { + const { t, locale } = useI18n(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + const [timeline, setTimeline] = useState(null); + const [timelineLoading, setTimelineLoading] = useState(true); + const [nowMs, setNowMs] = useState(() => Date.now()); + + const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start)); + const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end)); + + const requestedRange = + (searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.requestedMode ?? initialData.range.mode; + const selectedRange = requestedRange; + const shiftAvailable = initialData.range.shiftAvailable ?? true; + const shiftFallbackReason = initialData.range.fallbackReason; + const shiftFallbackActive = selectedRange === "shift" && initialData.range.mode !== "shift"; + + function pushRange(nextRange: RecapRangeMode, start?: string, end?: string) { + const params = new URLSearchParams(searchParams.toString()); + params.set("range", nextRange); + + if (nextRange === "custom" && start && end) { + params.set("start", start); + params.set("end", end); + } else { + params.delete("start"); + params.delete("end"); + } + + startTransition(() => { + router.push(`${pathname}?${params.toString()}`); + }); + } + + function applyCustomRange() { + const start = normalizeInputDate(customStart); + const end = normalizeInputDate(customEnd); + if (!start || !end || end <= start) return; + pushRange("custom", start, end); + } + + const machine = initialData.machine; + const generatedAtMs = new Date(initialData.generatedAt).getTime(); + const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null; + const timelineStart = timeline?.range.start ?? initialData.range.start; + const timelineEnd = timeline?.range.end ?? initialData.range.end; + const timelineSegments = timeline?.segments ?? []; + const timelineHasData = timeline?.hasData ?? false; + + useEffect(() => { + let alive = true; + setTimeline(null); + setTimelineLoading(true); + + async function loadTimeline() { + try { + const params = new URLSearchParams({ + start: initialData.range.start, + end: initialData.range.end, + }); + const res = await fetch(`/api/recap/${machineId}/timeline?${params.toString()}`, { cache: "no-store" }); + const json = await res.json().catch(() => null); + if (!alive || !res.ok || !json) return; + setTimeline(json as RecapTimelineResponse); + } catch { + } finally { + if (alive) setTimelineLoading(false); + } + } + + void loadTimeline(); + return () => { + alive = false; + }; + }, [initialData.range.end, initialData.range.start, machineId]); + + useEffect(() => { + const timer = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + + return ( +
+
+
+ + {`← ${t("recap.detail.back")}`} + +

{machine.name || machineId}

+
{machine.location || t("common.na")}
+ {freshAgeSec != null ? ( +
{t("recap.grid.updatedAgo", { sec: freshAgeSec })}
+ ) : null} +
+ +
+ {(["24h", "shift", "yesterday", "custom"] as const).map((range) => ( + + ))} +
+
+ + {!shiftAvailable ? ( +
+ {t("recap.range.shiftUnavailable")} +
+ ) : null} + + {shiftFallbackActive ? ( +
+ {shiftFallbackReason === "shift-inactive" ? t("recap.range.shiftFallbackInactive") : t("recap.range.shiftFallbackUnavailable")} +
+ ) : null} + + {selectedRange === "custom" ? ( +
+ setCustomStart(event.target.value)} + className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200" + /> + setCustomEnd(event.target.value)} + className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200" + /> + +
+ ) : null} + + {isPending ?
{t("common.loading")}
: null} + +
+ +
+ + + +
+ +
+ +
+ + +
+ +
+ +
+ +
+ +
+
+ ); +} diff --git a/app/api/machines/route.ts.bak b/app/api/machines/route.ts.bak new file mode 100644 index 0000000..97f1f54 --- /dev/null +++ b/app/api/machines/route.ts.bak @@ -0,0 +1,161 @@ +import { NextResponse } from "next/server"; +import { randomBytes } from "crypto"; +import { prisma } from "@/lib/prisma"; +import { generatePairingCode } from "@/lib/pairingCode"; +import { z } from "zod"; +import { logLine } from "@/lib/logger"; +import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming"; +import { requireSession } from "@/lib/auth/requireSession"; +import { + fetchLatestHeartbeats, + fetchLatestKpis, + fetchMachineBase, + mergeMachineOverviewRows, +} from "@/lib/machines/withLatest"; + +let machinesColdStart = true; + +function getColdStartInfo() { + const coldStart = machinesColdStart; + machinesColdStart = false; + return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) }; +} + +const createMachineSchema = z.object({ + name: z.string().trim().min(1).max(80), + code: z.string().trim().max(40).optional(), + location: z.string().trim().max(80).optional(), +}); + +export async function GET(req: Request) { + const perfEnabled = PERF_LOGS_ENABLED; + const totalStart = nowMs(); + const timings: Record = {}; + const { coldStart, uptimeMs } = getColdStartInfo(); + const url = new URL(req.url); + const includeKpi = url.searchParams.get("includeKpi") === "1"; + + const authStart = nowMs(); + const session = await requireSession(); + if (perfEnabled) timings.auth = elapsedMs(authStart); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const preQueryStart = nowMs(); + const machinesStart = nowMs(); + if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart); + const machines = await fetchMachineBase(session.orgId); + if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart); + + const heartbeatStart = nowMs(); + const machineIds = machines.map((machine) => machine.id); + const heartbeats = await fetchLatestHeartbeats(session.orgId, machineIds); + if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart); + + let kpis: Awaited> = []; + if (includeKpi) { + const kpiStart = nowMs(); + kpis = await fetchLatestKpis(session.orgId, machineIds); + if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart); + } + + const postQueryStart = nowMs(); + + // flatten latest heartbeat for UI convenience + const out = mergeMachineOverviewRows({ + machines, + heartbeats, + kpis, + includeKpi, + }); + + const payload = { ok: true, machines: out }; + + const responseHeaders = new Headers(); + if (perfEnabled) { + timings.postQuery = elapsedMs(postQueryStart); + timings.total = elapsedMs(totalStart); + responseHeaders.set("Server-Timing", formatServerTiming(timings)); + const payloadBytes = Buffer.byteLength(JSON.stringify(payload)); + logLine("perf.machines.api", { + orgId: session.orgId, + coldStart, + uptimeMs, + timings, + counts: { machines: out.length }, + payloadBytes, + }); + } + + return NextResponse.json(payload, { headers: responseHeaders }); +} + +export async function POST(req: Request) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const body = await req.json().catch(() => ({})); + const parsed = createMachineSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ ok: false, error: "Invalid machine payload" }, { status: 400 }); + } + + const name = parsed.data.name; + const codeRaw = parsed.data.code ?? ""; + const locationRaw = parsed.data.location ?? ""; + + const existing = await prisma.machine.findFirst({ + where: { orgId: session.orgId, name }, + select: { id: true }, + }); + + if (existing) { + return NextResponse.json({ ok: false, error: "Machine name already exists" }, { status: 409 }); + } + + const apiKey = randomBytes(24).toString("hex"); + const pairingExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + let machine = null as null | { + id: string; + name: string; + code?: string | null; + location?: string | null; + pairingCode?: string | null; + pairingCodeExpiresAt?: Date | null; + }; + + for (let attempt = 0; attempt < 5; attempt += 1) { + const pairingCode = generatePairingCode(); + try { + machine = await prisma.machine.create({ + data: { + orgId: session.orgId, + name, + code: codeRaw || null, + location: locationRaw || null, + apiKey, + pairingCode, + pairingCodeExpiresAt: pairingExpiresAt, + }, + select: { + id: true, + name: true, + code: true, + location: true, + pairingCode: true, + pairingCodeExpiresAt: true, + }, + }); + break; + } catch (err: unknown) { + const code = typeof err === "object" && err !== null ? (err as { code?: string }).code : undefined; + if (code !== "P2002") throw err; + } + } + + if (!machine?.pairingCode) { + return NextResponse.json({ ok: false, error: "Failed to generate pairing code" }, { status: 500 }); + } + + return NextResponse.json({ ok: true, machine }); +} diff --git a/components/recap/RecapMachineCard.tsx b/components/recap/RecapMachineCard.tsx index dbfbf60..7a56018 100644 --- a/components/recap/RecapMachineCard.tsx +++ b/components/recap/RecapMachineCard.tsx @@ -37,6 +37,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 timelineSegments = timeline?.segments ?? machine.miniTimeline; const timelineStart = timeline?.range.start ?? rangeStart; const timelineEnd = timeline?.range.end ?? rangeEnd; @@ -83,7 +85,11 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop return (
@@ -136,7 +142,12 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
) : null} -
{footerText}
+
+ {isUrgent + ? t("recap.card.stoppedFor", { min: ongoingStopMin }) + + (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "") + : footerText} +
); } diff --git a/components/recap/RecapMachineCard.tsx.bak b/components/recap/RecapMachineCard.tsx.bak new file mode 100644 index 0000000..dbfbf60 --- /dev/null +++ b/components/recap/RecapMachineCard.tsx.bak @@ -0,0 +1,142 @@ +"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 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} + +
{footerText}
+ + ); +} diff --git a/lib/i18n/en.json b/lib/i18n/en.json index aec10de..5f3c9ab 100644 --- a/lib/i18n/en.json +++ b/lib/i18n/en.json @@ -111,6 +111,7 @@ "overview.recap.cta": "Open daily recap", "recap.title": "Recap", "recap.subtitle": "Last 24h", + "recap.card.stoppedFor": "Stopped for {min} min", "recap.grid.title": "Machine recap", "recap.grid.subtitle": "Last 24h · click to open details", "recap.grid.updatedAgo": "Updated {sec}s ago", diff --git a/lib/i18n/es-MX.json b/lib/i18n/es-MX.json index c6b8618..27b63f2 100644 --- a/lib/i18n/es-MX.json +++ b/lib/i18n/es-MX.json @@ -118,6 +118,7 @@ "overview.recap.cta": "Abrir resumen diario", "recap.title": "Resumen", "recap.subtitle": "Últimas 24h", + "recap.card.stoppedFor": "Detenida hace {min} min", "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/machines/withLatest.ts.bak b/lib/machines/withLatest.ts.bak new file mode 100644 index 0000000..cf2edcd --- /dev/null +++ b/lib/machines/withLatest.ts.bak @@ -0,0 +1,113 @@ +import { prisma } from "@/lib/prisma"; +import type { OverviewMachineRow } from "@/lib/overview/types"; + +type MachineBaseRow = Pick< + OverviewMachineRow, + "id" | "name" | "code" | "location" | "createdAt" | "updatedAt" +>; + +type LatestHeartbeatRow = { + machineId: string; + ts: Date; + tsServer: Date | null; + status: string; + message?: string | null; + ip?: string | null; + fwVersion?: string | null; +}; + +type LatestKpiRow = { + machineId: string; + ts: Date; + oee?: number | null; + availability?: number | null; + performance?: number | null; + quality?: number | null; + workOrderId?: string | null; + sku?: string | null; + good?: number | null; + scrap?: number | null; + target?: number | null; + cycleTime?: number | null; +}; + +export async function fetchMachineBase(orgId: string): Promise { + return prisma.machine.findMany({ + where: { orgId }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + name: true, + code: true, + location: true, + createdAt: true, + updatedAt: true, + }, + }); +} + +export async function fetchLatestHeartbeats( + orgId: string, + machineIds: string[] +): Promise { + if (!machineIds.length) return []; + return prisma.machineHeartbeat.findMany({ + where: { orgId, machineId: { in: machineIds } }, + orderBy: [{ machineId: "asc" }, { tsServer: "desc" }], + distinct: ["machineId"], + select: { + machineId: true, + ts: true, + tsServer: true, + status: true, + message: true, + ip: true, + fwVersion: true, + }, + }); +} + +export async function fetchLatestKpis( + orgId: string, + machineIds: string[] +): Promise { + if (!machineIds.length) return []; + return prisma.machineKpiSnapshot.findMany({ + where: { orgId, machineId: { in: machineIds } }, + orderBy: [{ machineId: "asc" }, { ts: "desc" }], + distinct: ["machineId"], + select: { + machineId: true, + ts: true, + oee: true, + availability: true, + performance: true, + quality: true, + workOrderId: true, + sku: true, + good: true, + scrap: true, + target: true, + cycleTime: true, + }, + }); +} + +export function mergeMachineOverviewRows(params: { + machines: MachineBaseRow[]; + heartbeats: LatestHeartbeatRow[]; + kpis?: LatestKpiRow[]; + includeKpi?: boolean; +}): OverviewMachineRow[] { + const { machines, heartbeats, kpis = [], includeKpi = false } = params; + const heartbeatMap = new Map(heartbeats.map((row) => [row.machineId, row])); + const kpiMap = new Map(kpis.map((row) => [row.machineId, row])); + + return machines.map((machine) => ({ + ...machine, + latestHeartbeat: (heartbeatMap.get(machine.id) ?? null) as OverviewMachineRow["latestHeartbeat"], + latestKpi: includeKpi ? (kpiMap.get(machine.id) ?? null) : null, + heartbeats: undefined, + kpiSnapshots: undefined, + })); +} diff --git a/lib/recap/redesign.ts b/lib/recap/redesign.ts index a97fc6c..f399bc4 100644 --- a/lib/recap/redesign.ts +++ b/lib/recap/redesign.ts @@ -175,24 +175,90 @@ function addDays(input: { year: number; month: number; day: number }, days: numb }; } -function statusFromMachine(machine: RecapMachine, endMs: number) { +// 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; - const ongoingStopMin = machine.downtime.ongoingStopMin ?? 0; + // 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 > 0) status = "stopped"; + else if (ongoingStopMin != null && ongoingStopMin > 0) status = "stopped"; return { status, lastSeenMs, offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null, - ongoingStopMin: machine.downtime.ongoingStopMin, + ongoingStopMin, }; } @@ -281,9 +347,10 @@ function toSummaryMachine(params: { machine: RecapMachine; miniTimeline: ReturnType; rangeEndMs: number; + events?: TimelineEventRow[]; }): RecapSummaryMachine { - const { machine, miniTimeline, rangeEndMs } = params; - const status = statusFromMachine(machine, rangeEndMs); + const { machine, miniTimeline, rangeEndMs, events } = params; + const status = statusFromMachine(machine, rangeEndMs, events); return { machineId: machine.machineId, @@ -349,6 +416,7 @@ async function computeRecapSummary(params: { orgId: string; hours: number }) { machine, miniTimeline, rangeEndMs: end.getTime(), + events: timelineRows.eventsByMachine.get(machine.machineId), }); }); @@ -608,7 +676,11 @@ async function computeRecapMachineDetail(params: { rangeEnd: range.end, }); - const status = statusFromMachine(machine, range.end.getTime()); + 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) => ({ diff --git a/lib/recap/redesign.ts.bak b/lib/recap/redesign.ts.bak new file mode 100644 index 0000000..a97fc6c --- /dev/null +++ b/lib/recap/redesign.ts.bak @@ -0,0 +1,776 @@ +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(), + }; +} + +function statusFromMachine(machine: RecapMachine, endMs: number) { + 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 ongoingStopMin = machine.downtime.ongoingStopMin ?? 0; + const moldActive = machine.workOrders.moldChangeInProgress; + + let status: RecapMachineStatus = "running"; + if (offline) status = "offline"; + else if (moldActive) status = "mold-change"; + else if (ongoingStopMin > 0) status = "stopped"; + + return { + status, + lastSeenMs, + offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null, + ongoingStopMin: machine.downtime.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; +}): RecapSummaryMachine { + const { machine, miniTimeline, rangeEndMs } = params; + const status = statusFromMachine(machine, rangeEndMs); + + 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(), + }); + }); + + 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()); + + 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/timeline.ts b/lib/recap/timeline.ts index 7937fc8..47544f7 100644 --- a/lib/recap/timeline.ts +++ b/lib/recap/timeline.ts @@ -622,27 +622,33 @@ export function buildTimelineSegments(input: { if (!TIMELINE_EVENT_TYPES.includes(eventType)) continue; const data = extractData(event.data); - const isUpdate = safeBool(data.is_update ?? data.isUpdate); const isAutoAck = safeBool(data.is_auto_ack ?? data.isAutoAck); - if (isUpdate || isAutoAck) continue; + if (isAutoAck) continue; const tsMs = event.ts.getTime(); const key = eventIncidentKey(eventType, data, tsMs); const status = String(data.status ?? "").trim().toLowerCase(); - const episode = eventEpisodes.get(key) ?? { - type: eventType, - firstTsMs: tsMs, - lastTsMs: tsMs, - startMs: null, - endMs: null, - durationSec: null, - statusActive: false, - statusResolved: false, - reason: null, - fromMoldId: null, - toMoldId: null, - }; + let episode = eventEpisodes.get(key); + if (!episode) { + episode = { + type: eventType, + firstTsMs: tsMs, + lastTsMs: tsMs, + startMs: null, + endMs: null, + durationSec: null, + statusActive: false, + statusResolved: false, + reason: null, + fromMoldId: null, + toMoldId: null, + }; + } else if ((PRIORITY[eventType] ?? 0) > (PRIORITY[episode.type] ?? 0)) { + // Upgrade type when escalation is detected within the same incidentKey + // (e.g. microstop → macrostop preserves the same key by design) + episode.type = eventType; + } episode.firstTsMs = Math.min(episode.firstTsMs, tsMs); episode.lastTsMs = Math.max(episode.lastTsMs, tsMs);