almost final
This commit is contained in:
@@ -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) {
|
||||
|
||||
240
app/(app)/recap/[machineId]/RecapDetailClient.tsx.bak
Normal file
240
app/(app)/recap/[machineId]/RecapDetailClient.tsx.bak
Normal file
@@ -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<RecapTimelineResponse | null>(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 (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<Link href="/recap" className="text-sm text-zinc-400 hover:text-zinc-200">
|
||||
{`← ${t("recap.detail.back")}`}
|
||||
</Link>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-white">{machine.name || machineId}</h1>
|
||||
<div className="text-sm text-zinc-400">{machine.location || t("common.na")}</div>
|
||||
{freshAgeSec != null ? (
|
||||
<div className="mt-1 text-xs text-zinc-500">{t("recap.grid.updatedAgo", { sec: freshAgeSec })}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
{(["24h", "shift", "yesterday", "custom"] as const).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
type="button"
|
||||
disabled={range === "shift" && !shiftAvailable}
|
||||
onClick={() => {
|
||||
if (range === "shift" && !shiftAvailable) return;
|
||||
if (range === "custom") {
|
||||
pushRange("custom", normalizeInputDate(customStart) ?? undefined, normalizeInputDate(customEnd) ?? undefined);
|
||||
return;
|
||||
}
|
||||
pushRange(range);
|
||||
}}
|
||||
className={`rounded-xl border px-3 py-2 ${
|
||||
selectedRange === range
|
||||
? "border-emerald-300/60 bg-emerald-500/20 text-emerald-100"
|
||||
: "border-white/10 bg-black/40 text-zinc-200"
|
||||
} ${range === "shift" && !shiftAvailable ? "cursor-not-allowed opacity-60" : ""}`}
|
||||
>
|
||||
{range === "24h" ? t("recap.range.24h") : null}
|
||||
{range === "shift" ? t("recap.range.shiftCurrent") : null}
|
||||
{range === "yesterday" ? t("recap.range.yesterday") : null}
|
||||
{range === "custom" ? t("recap.range.custom") : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!shiftAvailable ? (
|
||||
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
||||
{t("recap.range.shiftUnavailable")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{shiftFallbackActive ? (
|
||||
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
||||
{shiftFallbackReason === "shift-inactive" ? t("recap.range.shiftFallbackInactive") : t("recap.range.shiftFallbackUnavailable")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedRange === "custom" ? (
|
||||
<div className="mb-4 flex flex-wrap gap-2 text-sm">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customStart}
|
||||
onChange={(event) => setCustomStart(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
/>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customEnd}
|
||||
onChange={(event) => setCustomEnd(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyCustomRange}
|
||||
className="rounded-xl border border-emerald-300/50 bg-emerald-500/20 px-3 py-2 text-emerald-100"
|
||||
>
|
||||
{t("recap.range.apply")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending ? <div className="mb-3 text-xs text-zinc-500">{t("common.loading")}</div> : null}
|
||||
|
||||
<div className="mb-4">
|
||||
<RecapBanners
|
||||
moldChangeStartMs={machine.moldChange?.active ? machine.moldChange.startMs : null}
|
||||
offlineForMin={machine.offlineForMin}
|
||||
ongoingStopMin={machine.ongoingStopMin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RecapKpiRow
|
||||
oeeAvg={machine.oee}
|
||||
goodParts={machine.goodParts}
|
||||
totalStops={Math.round(machine.stopMinutes)}
|
||||
scrapParts={machine.scrap}
|
||||
rangeMode={initialData.range.mode}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<RecapFullTimeline
|
||||
rangeStart={timelineStart}
|
||||
rangeEnd={timelineEnd}
|
||||
segments={timelineSegments}
|
||||
hasData={timelineHasData}
|
||||
loading={timelineLoading}
|
||||
locale={locale}
|
||||
rangeMode={initialData.range.mode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<RecapProductionBySku rows={machine.productionBySku} />
|
||||
<RecapDowntimeTop rows={machine.downtimeTop} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<RecapWorkOrders workOrders={machine.workOrders} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<RecapMachineStatus heartbeat={machine.heartbeat} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
app/api/machines/route.ts.bak
Normal file
161
app/api/machines/route.ts.bak
Normal file
@@ -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<string, number> = {};
|
||||
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<ReturnType<typeof fetchLatestKpis>> = [];
|
||||
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 });
|
||||
}
|
||||
@@ -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 (
|
||||
<Link
|
||||
href={`/recap/${machine.machineId}`}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80"
|
||||
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"
|
||||
: "border-white/10 bg-white/5 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@@ -136,7 +142,12 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 text-xs text-zinc-400">{footerText}</div>
|
||||
<div className={`mt-3 text-xs ${isUrgent ? "text-red-200 font-semibold" : "text-zinc-400"}`}>
|
||||
{isUrgent
|
||||
? t("recap.card.stoppedFor", { min: ongoingStopMin })
|
||||
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
|
||||
: footerText}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
142
components/recap/RecapMachineCard.tsx.bak
Normal file
142
components/recap/RecapMachineCard.tsx.bak
Normal file
@@ -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<RecapSummaryMachine["status"], string> = {
|
||||
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<RecapTimelineResponse | null>(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 (
|
||||
<Link
|
||||
href={`/recap/${machine.machineId}`}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold text-white">{machine.name}</div>
|
||||
<div className="mt-1 truncate text-xs text-zinc-400">{machine.location || t("common.na")}</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 px-2 py-1 text-xs text-zinc-200">
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 rounded-full ${STATUS_DOT[machine.status]}`}
|
||||
aria-label={statusLabel(machine.status, t)}
|
||||
/>
|
||||
{statusLabel(machine.status, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<div className={`text-3xl font-semibold ${machine.oee == null ? "text-zinc-400" : "text-white"}`}>{primaryMetric}</div>
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.card.oee")}</div>
|
||||
</div>
|
||||
{machine.oee == null ? <div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div> : null}
|
||||
|
||||
{zeroActivity ? <div className="mt-1 text-xs text-zinc-500">{t("recap.card.noProduction")}</div> : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-300">
|
||||
<span>{t("recap.card.good")}: {machine.goodParts}</span>
|
||||
<span>{t("recap.card.scrap")}: {machine.scrap}</span>
|
||||
<span>{t("recap.card.stops")}: {machine.stopsCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<RecapMiniTimeline
|
||||
rangeStart={timelineStart}
|
||||
rangeEnd={timelineEnd}
|
||||
segments={timelineSegments}
|
||||
locale={locale}
|
||||
hasData={hasTimelineData}
|
||||
muted={zeroActivity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{machine.moldChange?.active ? (
|
||||
<div className="mt-3 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
|
||||
{t("recap.card.moldChangeActive", { min: toInt(moldMinutes) })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{machine.offlineForMin != null && machine.offlineForMin > 10 ? (
|
||||
<div className="mt-2 rounded-lg border border-red-500/40 bg-red-500/10 px-2 py-1.5 text-xs text-red-200">
|
||||
{t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 text-xs text-zinc-400">{footerText}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
113
lib/machines/withLatest.ts.bak
Normal file
113
lib/machines/withLatest.ts.bak
Normal file
@@ -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<MachineBaseRow[]> {
|
||||
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<LatestHeartbeatRow[]> {
|
||||
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<LatestKpiRow[]> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -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<string, Episode>();
|
||||
|
||||
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<string, unknown> =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
// 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<typeof compressTimelineSegments>;
|
||||
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) => ({
|
||||
|
||||
776
lib/recap/redesign.ts.bak
Normal file
776
lib/recap/redesign.ts.bak
Normal file
@@ -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<string, ShiftOverrideDay> = {
|
||||
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<string, TimelineCycleRow[]>(),
|
||||
eventsByMachine: new Map<string, TimelineEventRow[]>(),
|
||||
};
|
||||
}
|
||||
|
||||
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<string, TimelineCycleRow[]>();
|
||||
const eventsByMachine = new Map<string, TimelineEventRow[]>();
|
||||
|
||||
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<typeof compressTimelineSegments>;
|
||||
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<string, string | string[] | undefined>) {
|
||||
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();
|
||||
}
|
||||
@@ -622,15 +622,16 @@ 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) ?? {
|
||||
let episode = eventEpisodes.get(key);
|
||||
if (!episode) {
|
||||
episode = {
|
||||
type: eventType,
|
||||
firstTsMs: tsMs,
|
||||
lastTsMs: tsMs,
|
||||
@@ -643,6 +644,11 @@ export function buildTimelineSegments(input: {
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user