reliability semi-fix

This commit is contained in:
Marcelo
2026-04-24 14:06:15 +00:00
parent 4973c18dc3
commit 6aaafb9115
32 changed files with 3749 additions and 1093 deletions

View File

@@ -20,6 +20,14 @@ import {
} from "recharts";
import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types";
import {
computeWidths,
formatDuration,
formatTime,
normalizeTimelineSegments,
TIMELINE_COLORS,
} from "@/components/recap/timelineRender";
type Heartbeat = {
ts: string;
@@ -87,20 +95,6 @@ type Thresholds = {
type TimelineState = "normal" | "slow" | "microstop" | "macrostop";
type TimelineSeg = {
start: number;
end: number;
durationSec: number;
state: TimelineState;
};
type ActiveStoppage = {
state: "microstop" | "macrostop";
startedAt: string;
durationSec: number;
theoreticalCycleTime: number;
};
type UploadState = {
status: "idle" | "parsing" | "uploading" | "success" | "error";
message?: string;
@@ -324,7 +318,6 @@ export default function MachineDetailClient() {
const [error, setError] = useState<string | null>(null);
const [cycles, setCycles] = useState<CycleRow[]>([]);
const [thresholds, setThresholds] = useState<Thresholds | null>(null);
const [activeStoppage, setActiveStoppage] = useState<ActiveStoppage | null>(null);
const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [uploadState, setUploadState] = useState<UploadState>({ status: "idle" });
@@ -372,7 +365,6 @@ export default function MachineDetailClient() {
setEventsCountAll(typeof json.eventsCountAll === "number" ? json.eventsCountAll : null);
setCycles(json.cycles ?? []);
setThresholds(json.thresholds ?? null);
setActiveStoppage(json.activeStoppage ?? null);
setError(null);
if (initial) setLoading(false);
} catch {
@@ -691,84 +683,45 @@ export default function MachineDetailClient() {
);
}
function MachineActivityTimeline({
cycles,
cycleTarget,
thresholds,
activeStoppage,
}: {
cycles: CycleRow[];
cycleTarget: number | null;
thresholds: Thresholds | null;
activeStoppage: ActiveStoppage | null;
}) {
const [nowMs, setNowMs] = useState(() => Date.now());
function MachineActivityTimeline({ machineId }: { machineId: string | undefined }) {
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [timelineLoading, setTimelineLoading] = useState(true);
useEffect(() => {
const timer = setInterval(() => setNowMs(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
if (!machineId) return;
let alive = true;
const timeline = useMemo(() => {
const rows = cycles ?? [];
const windowSec = rows.length < 1 ? 10800 : 3600;
const end = nowMs;
const start = end - windowSec * 1000;
if (rows.length < 1) {
return {
windowSec,
segments: [] as TimelineSeg[],
start,
end,
};
}
const segs: TimelineSeg[] = [];
for (const cycle of rows) {
const ideal = (cycle.ideal ?? cycleTarget ?? 0) as number;
const actual = cycle.actual ?? 0;
if (!ideal || ideal <= 0 || !actual || actual <= 0) continue;
const cycleEnd = cycle.t;
const cycleStart = cycleEnd - actual * 1000;
if (cycleEnd <= start || cycleStart >= end) continue;
const segStart = Math.max(cycleStart, start);
const segEnd = Math.min(cycleEnd, end);
if (segEnd <= segStart) continue;
const state = classifyCycleDuration(actual, ideal, thresholds);
segs.push({
start: segStart,
end: segEnd,
durationSec: (segEnd - segStart) / 1000,
state,
});
}
if (activeStoppage?.startedAt) {
const stoppageStart = new Date(activeStoppage.startedAt).getTime();
const segStart = Math.max(stoppageStart, start);
const segEnd = Math.min(end, nowMs);
if (segEnd > segStart) {
segs.push({
start: segStart,
end: segEnd,
durationSec: (segEnd - segStart) / 1000,
state: activeStoppage.state,
});
async function loadTimeline() {
try {
const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" });
const json = await res.json().catch(() => null);
if (!alive || !res.ok || !json) return;
setTimeline(json as RecapTimelineResponse);
} finally {
if (alive) setTimelineLoading(false);
}
}
segs.sort((a, b) => a.start - b.start);
void loadTimeline();
const timer = window.setInterval(() => {
void loadTimeline();
}, 30000);
return { windowSec, segments: segs, start, end };
}, [activeStoppage, cycles, cycleTarget, nowMs, thresholds]);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [machineId]);
const { segments, windowSec } = timeline;
const hasData = timeline?.hasData ?? false;
const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000;
const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now();
const totalMs = Math.max(1, endMs - startMs);
const normalized = useMemo(() => {
if (!timeline || !hasData) return [] as RecapTimelineSegment[];
return normalizeTimelineSegments(timeline.segments, startMs, endMs);
}, [timeline, hasData, startMs, endMs]);
const widths = useMemo(() => computeWidths(normalized, totalMs, 1.5), [normalized, totalMs]);
return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
@@ -777,49 +730,56 @@ export default function MachineDetailClient() {
<div className="text-sm font-semibold text-white">{t("machine.detail.activity.title")}</div>
<div className="mt-1 text-xs text-zinc-400">{t("machine.detail.activity.subtitle")}</div>
</div>
<div className="text-xs text-zinc-400">{windowSec}s</div>
<div className="text-xs text-zinc-400">1h</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
{(["normal", "slow", "microstop", "macrostop"] as const).map((key) => (
<div key={key} className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: BUCKET[key].dot }} />
<span>{t(BUCKET[key].labelKey)}</span>
{(["production", "mold-change", "macrostop", "microstop", "idle"] as const).map((type) => (
<div key={type} className="flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${TIMELINE_COLORS[type]}`} />
<span>
{type === "production" ? t("recap.timeline.type.production") : null}
{type === "mold-change" ? t("recap.timeline.type.moldChange") : null}
{type === "macrostop" ? t("recap.timeline.type.macrostop") : null}
{type === "microstop" ? t("recap.timeline.type.microstop") : null}
{type === "idle" ? t("recap.timeline.type.idle") : null}
</span>
</div>
))}
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4">
<div className="mb-2 flex justify-between text-[11px] text-zinc-500">
<span>0s</span>
<span>1h</span>
<span>{timelineLoading ? t("common.loading") : formatTime(startMs, locale)}</span>
<span>{formatTime(endMs, locale)}</span>
</div>
<div className="flex h-14 w-full overflow-hidden rounded-2xl">
{segments.length === 0 ? (
{!hasData ? (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
{t("machine.detail.activity.noData")}
</div>
) : (
segments.map((seg, idx) => {
const wPct = Math.max(0, (seg.durationSec / windowSec) * 100);
const meta = BUCKET[seg.state];
const glow =
seg.state === "microstop" || seg.state === "macrostop"
? `0 0 22px ${meta.glow}`
: `0 0 12px ${meta.glow}`;
normalized.map((segment, idx) => {
const widthPct = widths[idx] ?? 0;
const typeLabel =
segment.type === "production"
? t("recap.timeline.type.production")
: segment.type === "mold-change"
? t("recap.timeline.type.moldChange")
: segment.type === "macrostop"
? t("recap.timeline.type.macrostop")
: segment.type === "microstop" || segment.type === "slow-cycle"
? t("recap.timeline.type.microstop")
: t("recap.timeline.type.idle");
const title = `${typeLabel} · ${formatDuration(segment.startMs, segment.endMs)}`;
return (
<div
key={`${seg.start}-${seg.end}-${idx}`}
title={`${t(meta.labelKey)}: ${seg.durationSec.toFixed(1)}s`}
className="h-full"
style={{
width: `${wPct}%`,
background: meta.dot,
boxShadow: glow,
opacity: 0.95,
}}
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${idx}`}
title={title}
className={`h-full ${TIMELINE_COLORS[segment.type]}`}
style={{ width: `${Math.max(0, widthPct)}%` }}
/>
);
})
@@ -1106,12 +1066,19 @@ export default function MachineDetailClient() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">OEE</div>
<div className="mt-2 text-3xl font-bold text-emerald-300">{fmtPct(kpi?.oee)}</div>
{kpi?.oee == null || Number.isNaN(kpi.oee) ? (
<div className="mt-2 text-3xl font-bold text-zinc-400"></div>
) : (
<div className="mt-2 text-3xl font-bold text-emerald-300">{fmtPct(kpi?.oee)}</div>
)}
<div className="mt-1 text-xs text-zinc-400">
{t("machine.detail.kpi.updated", {
time: kpi?.ts ? timeAgo(kpi.ts) : t("common.never"),
})}
</div>
{kpi?.oee == null || Number.isNaN(kpi.oee) ? (
<div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div>
) : null}
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
@@ -1131,12 +1098,7 @@ export default function MachineDetailClient() {
</div>
<div className="mt-6">
<MachineActivityTimeline
cycles={cycles}
cycleTarget={cycleTarget}
thresholds={thresholds}
activeStoppage={activeStoppage}
/>
<MachineActivityTimeline machineId={machineId} />
</div>
{!screenlessMode && (
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">

View File

@@ -1,310 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapMachine, RecapResponse, RecapTimelineResponse } from "@/lib/recap/types";
import RecapKpiRow from "@/components/recap/RecapKpiRow";
import RecapProductionBySku from "@/components/recap/RecapProductionBySku";
import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop";
import RecapWorkOrderStatus from "@/components/recap/RecapWorkOrderStatus";
import RecapMachineStatus from "@/components/recap/RecapMachineStatus";
import RecapTimeline from "@/components/recap/RecapTimeline";
type Props = {
initialData: RecapResponse;
initialFilters: {
machineId: string;
shift: string;
start: string;
end: string;
};
};
type RangeMode = "24h" | "shift" | "custom";
function toInputDate(value: string) {
if (!value) return "";
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 toMinutesLabel(minutes: number | null) {
if (minutes == null || minutes <= 0) return "0";
return String(Math.round(minutes));
}
export default function RecapClient({ initialData, initialFilters }: Props) {
const { t, locale } = useI18n();
const [data, setData] = useState<RecapResponse>(initialData);
const [machineId, setMachineId] = useState(initialFilters.machineId || "");
const [shift, setShift] = useState(initialFilters.shift || "shift1");
const [customStart, setCustomStart] = useState(toInputDate(initialFilters.start));
const [customEnd, setCustomEnd] = useState(toInputDate(initialFilters.end));
const [mode, setMode] = useState<RangeMode>(() => {
if (initialFilters.shift) return "shift";
if (initialFilters.start || initialFilters.end) return "custom";
return "24h";
});
const [loading, setLoading] = useState(false);
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const shiftOptions = useMemo(
() =>
data.availableShifts?.length
? data.availableShifts
: [
{ id: "shift1", name: t("recap.shift.1") },
{ id: "shift2", name: t("recap.shift.2") },
{ id: "shift3", name: t("recap.shift.3") },
],
[data.availableShifts, t]
);
useEffect(() => {
if (mode !== "shift") return;
if (shiftOptions.some((option) => option.id === shift)) return;
setShift(shiftOptions[0]?.id ?? "shift1");
}, [mode, shift, shiftOptions]);
useEffect(() => {
let alive = true;
async function load() {
setLoading(true);
const qs = new URLSearchParams();
if (machineId) qs.set("machineId", machineId);
if (mode === "shift") qs.set("shift", shift || "shift1");
if (mode === "custom") {
if (customStart) qs.set("start", new Date(customStart).toISOString());
if (customEnd) qs.set("end", new Date(customEnd).toISOString());
}
try {
const res = await fetch(`/api/recap?${qs.toString()}`, { cache: "no-cache" });
const json = await res.json().catch(() => null);
if (!alive || !json) return;
setData(json as RecapResponse);
} finally {
if (alive) setLoading(false);
}
}
const timeout = setTimeout(load, 200);
return () => {
alive = false;
clearTimeout(timeout);
};
}, [machineId, mode, shift, customStart, customEnd]);
useEffect(() => {
async function refresh() {
const qs = new URLSearchParams();
if (machineId) qs.set("machineId", machineId);
if (mode === "shift") qs.set("shift", shift || "shift1");
if (mode === "custom") {
if (customStart) qs.set("start", new Date(customStart).toISOString());
if (customEnd) qs.set("end", new Date(customEnd).toISOString());
}
const res = await fetch(`/api/recap?${qs.toString()}`, { cache: "no-cache" });
const json = await res.json().catch(() => null);
if (json) setData(json as RecapResponse);
}
const onFocus = () => {
void refresh();
};
const interval = window.setInterval(onFocus, 60000);
window.addEventListener("focus", onFocus);
return () => {
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
};
}, [machineId, mode, shift, customStart, customEnd]);
const selectedMachine = useMemo(() => {
if (!data.machines.length) return null;
return data.machines.find((m) => m.machineId === machineId) ?? data.machines[0];
}, [data.machines, machineId]);
useEffect(() => {
let alive = true;
async function loadTimeline() {
if (mode !== "24h") {
if (alive) setTimeline(null);
return;
}
if (!selectedMachine?.machineId) {
if (alive) setTimeline(null);
return;
}
const qs = new URLSearchParams({
machineId: selectedMachine.machineId,
hours: "24",
start: data.range.start,
end: data.range.end,
});
const res = await fetch(`/api/recap/timeline?${qs.toString()}`, { cache: "no-cache" });
const json = await res.json().catch(() => null);
if (!alive) return;
if (res.ok && json && json.segments) {
setTimeline(json as RecapTimelineResponse);
} else {
setTimeline(null);
}
}
void loadTimeline();
return () => {
alive = false;
};
}, [mode, selectedMachine?.machineId, data.range.start, data.range.end]);
const fleet = useMemo(() => {
let good = 0;
let scrap = 0;
let stops = 0;
let oeeSum = 0;
let oeeCount = 0;
for (const m of data.machines) {
good += m.production.goodParts;
scrap += m.production.scrapParts;
stops += m.downtime.stopsCount;
if (m.oee.avg != null) {
oeeSum += m.oee.avg;
oeeCount += 1;
}
}
return {
oeeAvg: oeeCount ? oeeSum / oeeCount : null,
good,
scrap,
stops,
};
}, [data.machines]);
const bannerMold = selectedMachine?.workOrders.moldChangeInProgress;
const moldStartMs = selectedMachine?.workOrders.moldChangeStartMs ?? null;
const moldStartLabel = moldStartMs
? new Date(moldStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
: "--:--";
const moldElapsedMin = moldStartMs ? Math.max(0, Math.floor((Date.now() - moldStartMs) / 60000)) : null;
const bannerStop = (selectedMachine?.downtime.ongoingStopMin ?? 0) > 0;
return (
<div className="p-4 sm:p-6">
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("recap.title")}</h1>
<p className="text-sm text-zinc-400">
{t("recap.subtitle")} · {new Date(data.range.start).toLocaleString(locale)} - {new Date(data.range.end).toLocaleString(locale)}
</p>
</div>
<div className="flex flex-wrap gap-2 text-sm">
<select
value={machineId}
onChange={(event) => setMachineId(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="">{t("recap.allMachines")}</option>
{data.machines.map((m) => (
<option key={m.machineId} value={m.machineId}>
{m.machineName}
</option>
))}
</select>
<select
value={mode}
onChange={(event) => setMode(event.target.value as RangeMode)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="24h">24h</option>
<option value="shift">{t("recap.range.shift")}</option>
<option value="custom">{t("recap.range.custom")}</option>
</select>
{mode === "shift" ? (
<select
value={shift}
onChange={(event) => setShift(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
{shiftOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
) : null}
{mode === "custom" ? (
<>
<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"
/>
</>
) : null}
</div>
</div>
</div>
{bannerMold ? (
<div className="mb-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-300">
{t("recap.banner.mold")} {moldStartLabel}
{moldElapsedMin != null ? ` · ${moldElapsedMin} min` : ""}
</div>
) : null}
{bannerStop ? (
<div className="mb-3 rounded-2xl border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
{t("recap.banner.stopped", { minutes: toMinutesLabel(selectedMachine?.downtime.ongoingStopMin ?? null) })}
</div>
) : null}
{loading ? <div className="mb-3 text-sm text-zinc-400">{t("common.loading")}</div> : null}
{timeline ? (
<RecapTimeline
rangeStart={timeline.range.start}
rangeEnd={timeline.range.end}
segments={timeline.segments}
locale={locale}
/>
) : null}
<RecapKpiRow oeeAvg={fleet.oeeAvg} goodParts={fleet.good} totalStops={fleet.stops} scrapParts={fleet.scrap} />
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
<RecapProductionBySku rows={selectedMachine?.production.bySku ?? []} />
<RecapDowntimeTop rows={selectedMachine?.downtime.topReasons ?? []} />
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
<RecapWorkOrderStatus
workOrders={
selectedMachine?.workOrders ?? {
completed: [],
active: null,
moldChangeInProgress: false,
moldChangeStartMs: null,
}
}
/>
<RecapMachineStatus machine={selectedMachine as RecapMachine | null} />
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapMachineStatus, RecapSummaryResponse } from "@/lib/recap/types";
import RecapMachineCard from "@/components/recap/RecapMachineCard";
type Props = {
initialData: RecapSummaryResponse;
};
function statusLabel(status: RecapMachineStatus, t: (key: string) => string) {
if (status === "running") return t("recap.status.running");
if (status === "mold-change") return t("recap.status.moldChange");
if (status === "stopped") return t("recap.status.stopped");
return t("recap.status.offline");
}
export default function RecapGridClient({ initialData }: Props) {
const { t } = useI18n();
const [data, setData] = useState<RecapSummaryResponse>(initialData);
const [loading, setLoading] = useState(false);
const [locationFilter, setLocationFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState<"all" | RecapMachineStatus>("all");
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
let alive = true;
async function refresh() {
setLoading(true);
try {
const res = await fetch(`/api/recap/summary?hours=${data.range.hours}`, { cache: "no-store" });
const json = await res.json().catch(() => null);
if (!alive || !json || !res.ok) return;
setData(json as RecapSummaryResponse);
} finally {
if (alive) setLoading(false);
}
}
const onFocus = () => {
void refresh();
};
const interval = window.setInterval(onFocus, 60000);
window.addEventListener("focus", onFocus);
return () => {
alive = false;
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
};
}, [data.range.hours]);
const locationOptions = useMemo(() => {
const set = new Set<string>();
for (const machine of data.machines) {
if (machine.location) set.add(machine.location);
}
return [...set].sort((a, b) => a.localeCompare(b));
}, [data.machines]);
const filteredMachines = useMemo(() => {
return data.machines.filter((machine) => {
if (locationFilter !== "all" && machine.location !== locationFilter) return false;
if (statusFilter !== "all" && machine.status !== statusFilter) return false;
return true;
});
}, [data.machines, locationFilter, statusFilter]);
const generatedAtMs = new Date(data.generatedAt).getTime();
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
return (
<div className="p-4 sm:p-6">
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("recap.grid.title")}</h1>
<p className="text-sm text-zinc-400">{t("recap.grid.subtitle")}</p>
{freshAgeSec != null ? (
<p className="mt-1 text-xs text-zinc-500">{t("recap.grid.updatedAgo", { sec: freshAgeSec })}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2 text-sm">
<select
value={locationFilter}
onChange={(event) => setLocationFilter(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="all">{t("recap.filter.allLocations")}</option>
{locationOptions.map((location) => (
<option key={location} value={location}>
{location}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "all" | RecapMachineStatus)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="all">{t("recap.filter.allStatuses")}</option>
{(["running", "mold-change", "stopped", "offline"] as const).map((status) => (
<option key={status} value={status}>
{statusLabel(status, t)}
</option>
))}
</select>
</div>
</div>
</div>
{loading && data.machines.length === 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
) : null}
{loading && data.machines.length > 0 ? (
<div className="mb-3 text-xs text-zinc-500">{t("common.loading")}</div>
) : null}
{filteredMachines.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-black/30 p-4 text-sm text-zinc-400">
{t("recap.grid.empty")}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filteredMachines.map((machine) => (
<RecapMachineCard
key={machine.machineId}
machine={machine}
rangeStart={data.range.start}
rangeEnd={data.range.end}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,213 @@
"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 [nowMs, setNowMs] = useState(() => Date.now());
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end));
const selectedRange = (searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.mode;
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 ?? machine.timeline;
const timelineHasData = timeline?.hasData ?? true;
useEffect(() => {
let alive = 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 {
}
}
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"
onClick={() => {
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 === "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>
{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}
/>
<div className="mt-4">
<RecapFullTimeline
rangeStart={timelineStart}
rangeEnd={timelineEnd}
segments={timelineSegments}
hasData={timelineHasData}
locale={locale}
/>
</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>
);
}

View File

@@ -0,0 +1,13 @@
export default function LoadingRecapDetail() {
return (
<div className="p-4 sm:p-6">
<div className="h-16 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-24 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
))}
</div>
<div className="mt-4 h-48 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { notFound, redirect } from "next/navigation";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapMachineDetailCached, parseRecapDetailRangeInput } from "@/lib/recap/redesign";
import RecapDetailClient from "./RecapDetailClient";
export default async function RecapMachineDetailPage({
params,
searchParams,
}: {
params: Promise<{ machineId: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const session = await requireSession();
const { machineId } = await params;
if (!session) redirect(`/login?next=/recap/${machineId}`);
const rawSearchParams = (await searchParams) ?? {};
const input = parseRecapDetailRangeInput(rawSearchParams);
const initialData = await getRecapMachineDetailCached({
orgId: session.orgId,
machineId,
input,
});
if (!initialData) notFound();
return (
<RecapDetailClient
key={`${machineId}:${initialData.range.mode}:${initialData.range.start}:${initialData.range.end}`}
machineId={machineId}
initialData={initialData}
/>
);
}

View File

@@ -0,0 +1,12 @@
export default function LoadingRecapGrid() {
return (
<div className="p-4 sm:p-6">
<div className="mb-4 h-24 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</div>
);
}

View File

@@ -1,46 +1,16 @@
import { redirect } from "next/navigation";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapDataCached, parseRecapQuery } from "@/lib/recap/getRecapData";
import RecapClient from "./RecapClient";
import { getRecapSummaryCached } from "@/lib/recap/redesign";
import RecapGridClient from "./RecapGridClient";
export default async function RecapPage({
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
export default async function RecapPage() {
const session = await requireSession();
if (!session) redirect("/login?next=/recap");
const params = (await searchParams) ?? {};
const getParam = (key: string) => {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
};
const parsed = parseRecapQuery({
machineId: getParam("machineId"),
start: getParam("start"),
end: getParam("end"),
shift: getParam("shift"),
});
const initialData = await getRecapDataCached({
const initialData = await getRecapSummaryCached({
orgId: session.orgId,
machineId: parsed.machineId,
start: parsed.start ?? undefined,
end: parsed.end ?? undefined,
shift: parsed.shift ?? undefined,
hours: 24,
});
return (
<RecapClient
initialData={initialData}
initialFilters={{
machineId: parsed.machineId ?? "",
shift: parsed.shift ?? "",
start: parsed.start?.toISOString() ?? "",
end: parsed.end?.toISOString() ?? "",
}}
/>
);
return <RecapGridClient initialData={initialData} />;
}

View File

@@ -0,0 +1,37 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapMachineDetailCached, parseRecapDetailRangeInput } from "@/lib/recap/redesign";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const { machineId } = await params;
if (!machineId) {
return NextResponse.json({ ok: false, error: "machineId is required" }, { status: 400 });
}
const url = new URL(req.url);
const input = parseRecapDetailRangeInput(url.searchParams);
const detail = await getRecapMachineDetailCached({
orgId: session.orgId,
machineId,
input,
});
if (!detail) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
return NextResponse.json(detail, {
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
},
});
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { prisma } from "@/lib/prisma";
import { getRecapTimelineForMachine, parseRecapTimelineRange } from "@/lib/recap/timelineApi";
function bad(status: number, error: string) {
return NextResponse.json({ ok: false, error }, { status });
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const session = await requireSession();
if (!session) return bad(401, "Unauthorized");
const { machineId } = await params;
if (!machineId) return bad(400, "machineId is required");
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true },
});
if (!machine) return bad(404, "Machine not found");
const url = new URL(req.url);
const { start, end, maxSegments } = parseRecapTimelineRange(url.searchParams);
const response = await getRecapTimelineForMachine({
orgId: session.orgId,
machineId,
start,
end,
maxSegments,
});
return NextResponse.json(response, {
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
},
});
}

View File

@@ -0,0 +1,21 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapSummaryCached, parseRecapSummaryHours } from "@/lib/recap/redesign";
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const hours = parseRecapSummaryHours(url.searchParams.get("hours"));
const summary = await getRecapSummaryCached({ orgId: session.orgId, hours });
return NextResponse.json(summary, {
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
},
});
}

View File

@@ -2,178 +2,12 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { prisma } from "@/lib/prisma";
import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types";
type RawSegment =
| {
type: "production";
startMs: number;
endMs: number;
priority: number;
workOrderId: string | null;
sku: string | null;
label: string;
}
| {
type: "mold-change";
startMs: number;
endMs: number;
priority: number;
fromMoldId: string | null;
toMoldId: string | null;
durationSec: number;
label: string;
}
| {
type: "macrostop" | "microstop" | "slow-cycle";
startMs: number;
endMs: number;
priority: number;
reason: string | null;
durationSec: number;
label: string;
};
const EVENT_TYPES = ["mold-change", "macrostop", "microstop", "slow-cycle"] as const;
type TimelineEventType = (typeof EVENT_TYPES)[number];
const ACTIVE_STALE_MS = 2 * 60 * 1000;
const PRIORITY: Record<string, number> = {
idle: 0,
production: 1,
microstop: 2,
"slow-cycle": 2,
macrostop: 3,
"mold-change": 4,
};
import { getRecapTimelineForMachine, parseRecapTimelineRange } from "@/lib/recap/timelineApi";
function bad(status: number, error: string) {
return NextResponse.json({ ok: false, error }, { status });
}
function safeNum(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function normalizeToken(value: unknown) {
return String(value ?? "").trim();
}
function dedupeByKey<T>(rows: T[], keyFn: (row: T) => string) {
const seen = new Set<string>();
const out: T[] = [];
for (const row of rows) {
const key = keyFn(row);
if (seen.has(key)) continue;
seen.add(key);
out.push(row);
}
return out;
}
function parseHours(raw: string | null) {
const value = Math.trunc(Number(raw || "24"));
if (!Number.isFinite(value)) return 24;
return Math.max(1, Math.min(72, value));
}
function parseDateInput(raw: string | null) {
if (!raw) return null;
const asNum = Number(raw);
if (Number.isFinite(asNum)) {
const d = new Date(asNum);
return Number.isFinite(d.getTime()) ? d : null;
}
const d = new Date(raw);
return Number.isFinite(d.getTime()) ? d : null;
}
function extractData(value: unknown) {
let parsed: unknown = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
parsed = null;
}
}
const record = typeof parsed === "object" && parsed && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
const nested = record.data;
if (typeof nested === "object" && nested && !Array.isArray(nested)) return nested as Record<string, unknown>;
return record;
}
function clampToRange(startMs: number, endMs: number, rangeStartMs: number, rangeEndMs: number) {
const clampedStart = Math.max(rangeStartMs, Math.min(rangeEndMs, startMs));
const clampedEnd = Math.max(rangeStartMs, Math.min(rangeEndMs, endMs));
if (clampedEnd <= clampedStart) return null;
return { startMs: clampedStart, endMs: clampedEnd };
}
function eventIncidentKey(eventType: string, data: Record<string, unknown>, fallbackTsMs: number) {
const key = String(data.incidentKey ?? data.incident_key ?? "").trim();
if (key) return key;
const alertId = String(data.alert_id ?? data.alertId ?? "").trim();
if (alertId) return `${eventType}:${alertId}`;
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`;
return `${eventType}:${fallbackTsMs}`;
}
function reasonLabelFromData(data: Record<string, unknown>) {
const direct =
String(data.reasonText ?? data.reason_label ?? data.reasonLabel ?? "").trim() || null;
if (direct) return direct;
const reason = data.reason;
if (typeof reason === "string") {
const text = reason.trim();
return text || null;
}
if (reason && typeof reason === "object" && !Array.isArray(reason)) {
const rec = reason as Record<string, unknown>;
const reasonText =
String(rec.reasonText ?? rec.reason_label ?? rec.reasonLabel ?? "").trim() || null;
if (reasonText) return reasonText;
const detail =
String(rec.detailLabel ?? rec.detail_label ?? rec.detailId ?? rec.detail_id ?? "").trim() || null;
const category =
String(rec.categoryLabel ?? rec.category_label ?? rec.categoryId ?? rec.category_id ?? "").trim() || null;
if (category && detail) return `${category} > ${detail}`;
if (detail) return detail;
if (category) return category;
}
return null;
}
function labelForStop(type: "macrostop" | "microstop" | "slow-cycle", reason: string | null) {
if (type === "macrostop") return reason ? `Paro: ${reason}` : "Paro";
if (type === "microstop") return reason ? `Microparo: ${reason}` : "Microparo";
return "Ciclo lento";
}
function isEquivalent(a: RecapTimelineSegment, b: RecapTimelineSegment) {
if (a.type !== b.type) return false;
if (a.type === "idle" && b.type === "idle") return true;
if (a.type === "production" && b.type === "production") {
return a.workOrderId === b.workOrderId && a.sku === b.sku && a.label === b.label;
}
if (a.type === "mold-change" && b.type === "mold-change") {
return a.fromMoldId === b.fromMoldId && a.toMoldId === b.toMoldId;
}
if (
(a.type === "macrostop" || a.type === "microstop" || a.type === "slow-cycle") &&
(b.type === "macrostop" || b.type === "microstop" || b.type === "slow-cycle")
) {
return a.type === b.type && a.reason === b.reason;
}
return false;
}
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) return bad(401, "Unauthorized");
@@ -181,9 +15,6 @@ export async function GET(req: NextRequest) {
const url = new URL(req.url);
const machineId = url.searchParams.get("machineId");
if (!machineId) return bad(400, "machineId is required");
const hours = parseHours(url.searchParams.get("hours"));
const startParam = parseDateInput(url.searchParams.get("start"));
const endParam = parseDateInput(url.searchParams.get("end"));
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
@@ -191,271 +22,18 @@ export async function GET(req: NextRequest) {
});
if (!machine) return bad(404, "Machine not found");
const end = endParam ?? new Date();
const start = startParam && startParam < end ? startParam : new Date(end.getTime() - hours * 60 * 60 * 1000);
const rangeStartMs = start.getTime();
const rangeEndMs = end.getTime();
const { start, end, maxSegments } = parseRecapTimelineRange(url.searchParams);
const response = await getRecapTimelineForMachine({
orgId: session.orgId,
machineId,
start,
end,
maxSegments,
});
const [cycles, events] = await Promise.all([
prisma.machineCycle.findMany({
where: {
orgId: session.orgId,
machineId,
ts: { gte: start, lte: end },
},
orderBy: { ts: "asc" },
select: {
ts: true,
cycleCount: true,
actualCycleTime: true,
workOrderId: true,
sku: true,
},
}),
prisma.machineEvent.findMany({
where: {
orgId: session.orgId,
machineId,
eventType: { in: EVENT_TYPES as unknown as string[] },
ts: { gte: new Date(start.getTime() - 24 * 60 * 60 * 1000), lte: end },
},
orderBy: { ts: "asc" },
select: {
ts: true,
eventType: true,
data: true,
},
}),
]);
const dedupedCycles = dedupeByKey(
cycles,
(cycle) =>
`${cycle.ts.getTime()}:${safeNum(cycle.cycleCount) ?? "na"}:${normalizeToken(cycle.workOrderId).toUpperCase()}:${normalizeToken(cycle.sku).toUpperCase()}:${safeNum(cycle.actualCycleTime) ?? "na"}`
);
const rawSegments: RawSegment[] = [];
let currentProduction: RawSegment | null = null;
for (const cycle of dedupedCycles) {
if (!cycle.workOrderId) continue;
const cycleStartMs = cycle.ts.getTime();
const cycleDurationMs = Math.max(1000, Math.min(600000, Math.trunc((safeNum(cycle.actualCycleTime) ?? 1) * 1000)));
const cycleEndMs = cycleStartMs + cycleDurationMs;
if (
currentProduction &&
currentProduction.type === "production" &&
currentProduction.workOrderId === cycle.workOrderId &&
currentProduction.sku === cycle.sku &&
cycleStartMs <= currentProduction.endMs + 5 * 60 * 1000
) {
currentProduction.endMs = Math.max(currentProduction.endMs, cycleEndMs);
continue;
}
if (currentProduction) rawSegments.push(currentProduction);
currentProduction = {
type: "production",
startMs: cycleStartMs,
endMs: cycleEndMs,
priority: PRIORITY.production,
workOrderId: cycle.workOrderId,
sku: cycle.sku,
label: cycle.workOrderId,
};
}
if (currentProduction) rawSegments.push(currentProduction);
const eventEpisodes = new Map<
string,
{
type: "mold-change" | "macrostop" | "microstop" | "slow-cycle";
firstTsMs: number;
lastTsMs: number;
startMs: number | null;
endMs: number | null;
durationSec: number | null;
statusActive: boolean;
statusResolved: boolean;
reason: string | null;
fromMoldId: string | null;
toMoldId: string | null;
}
>();
for (const event of events) {
const eventType = String(event.eventType || "").toLowerCase() as TimelineEventType;
if (!EVENT_TYPES.includes(eventType)) continue;
const data = extractData(event.data);
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,
};
episode.firstTsMs = Math.min(episode.firstTsMs, tsMs);
episode.lastTsMs = Math.max(episode.lastTsMs, tsMs);
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
const endMs = safeNum(data.end_ms) ?? safeNum(data.endMs);
const durationSec =
safeNum(data.duration_sec) ??
safeNum(data.stoppage_duration_seconds) ??
safeNum(data.stop_duration_seconds) ??
safeNum(data.duration_seconds);
if (startMs != null) episode.startMs = episode.startMs == null ? startMs : Math.min(episode.startMs, startMs);
if (endMs != null) episode.endMs = episode.endMs == null ? endMs : Math.max(episode.endMs, endMs);
if (durationSec != null) episode.durationSec = Math.max(0, Math.trunc(durationSec));
if (status === "active") episode.statusActive = true;
if (status === "resolved") episode.statusResolved = true;
const reason = reasonLabelFromData(data);
if (reason) episode.reason = reason;
const fromMoldId = String(data.from_mold_id ?? data.fromMoldId ?? "").trim() || null;
const toMoldId = String(data.to_mold_id ?? data.toMoldId ?? "").trim() || null;
if (fromMoldId) episode.fromMoldId = fromMoldId;
if (toMoldId) episode.toMoldId = toMoldId;
eventEpisodes.set(key, episode);
}
for (const episode of eventEpisodes.values()) {
const startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
let endMs = Math.trunc(episode.endMs ?? episode.lastTsMs);
if (episode.statusActive && !episode.statusResolved) {
const isFreshActive = rangeEndMs - episode.lastTsMs <= ACTIVE_STALE_MS;
endMs = isFreshActive ? rangeEndMs : episode.lastTsMs;
} else if (endMs <= startMs && episode.durationSec != null && episode.durationSec > 0) {
endMs = startMs + episode.durationSec * 1000;
}
if (endMs <= startMs) continue;
if (episode.type === "mold-change") {
rawSegments.push({
type: "mold-change",
startMs,
endMs,
priority: PRIORITY["mold-change"],
fromMoldId: episode.fromMoldId,
toMoldId: episode.toMoldId,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: episode.toMoldId ? `Cambio molde ${episode.toMoldId}` : "Cambio molde",
});
continue;
}
const stopType = episode.type;
rawSegments.push({
type: stopType,
startMs,
endMs,
priority: PRIORITY[stopType],
reason: episode.reason,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: labelForStop(stopType, episode.reason),
});
}
const clipped = rawSegments
.map((segment) => {
const range = clampToRange(segment.startMs, segment.endMs, rangeStartMs, rangeEndMs);
return range ? { ...segment, ...range } : null;
})
.filter((segment): segment is RawSegment => !!segment);
const boundaries = new Set<number>([rangeStartMs, rangeEndMs]);
for (const segment of clipped) {
boundaries.add(segment.startMs);
boundaries.add(segment.endMs);
}
const orderedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
const timeline: RecapTimelineSegment[] = [];
for (let i = 0; i < orderedBoundaries.length - 1; i += 1) {
const intervalStart = orderedBoundaries[i];
const intervalEnd = orderedBoundaries[i + 1];
if (intervalEnd <= intervalStart) continue;
const covering = clipped
.filter((segment) => segment.startMs < intervalEnd && segment.endMs > intervalStart)
.sort((a, b) => b.priority - a.priority || b.startMs - a.startMs);
const winner = covering[0];
if (!winner) {
timeline.push({ type: "idle", startMs: intervalStart, endMs: intervalEnd, label: "Idle" });
continue;
}
if (winner.type === "production") {
timeline.push({
type: "production",
startMs: intervalStart,
endMs: intervalEnd,
workOrderId: winner.workOrderId,
sku: winner.sku,
label: winner.label,
});
continue;
}
if (winner.type === "mold-change") {
timeline.push({
type: "mold-change",
startMs: intervalStart,
endMs: intervalEnd,
fromMoldId: winner.fromMoldId,
toMoldId: winner.toMoldId,
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
label: winner.label,
});
continue;
}
timeline.push({
type: winner.type,
startMs: intervalStart,
endMs: intervalEnd,
reason: winner.reason,
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
label: winner.label,
});
}
const merged: RecapTimelineSegment[] = [];
for (const segment of timeline) {
const prev = merged[merged.length - 1];
if (!prev || !isEquivalent(prev, segment) || prev.endMs !== segment.startMs) {
merged.push(segment);
continue;
}
prev.endMs = segment.endMs;
if (prev.type === "mold-change") prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000));
if (prev.type === "macrostop" || prev.type === "microstop" || prev.type === "slow-cycle") {
prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000));
}
}
const response: RecapTimelineResponse = {
range: {
start: start.toISOString(),
end: end.toISOString(),
return NextResponse.json(response, {
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
},
segments: merged,
};
return NextResponse.json(response);
});
}