diff --git a/Reliability.md b/Reliability.md
new file mode 100644
index 0000000..d080de1
--- /dev/null
+++ b/Reliability.md
@@ -0,0 +1,161 @@
+Data Reliability — Handoff Prompt
+Problem
+Same machine shows different numbers in 3 places:
+
+Home UI (Node-RED): goodParts=353, OEE=77.9%
+Recap: goodParts=185, OEE=56%
+Machine detail: OEE=4.3%, "Sin datos" in 1h timeline
+Root cause: each view queries a different table with different logic. No single source of truth.
+
+Rule: pick one source per metric, reuse across views
+Metric Authoritative source Why
+goodParts, scrapParts (per WO) MachineWorkOrder.good_parts / scrap_parts Node-RED writes this via UPDATE work_orders SET .... It's what Home UI shows.
+cycleCount MachineWorkOrder.cycle_count Same reason.
+oee / availability / performance / quality time-weighted avg of MachineKpiSnapshot rows in window Snapshots are minute-by-minute; Node-RED already sends them. Don't recompute.
+Stops (count, duration) MachineEvent filtered by eventType IN (microstop, macrostop, mold-change) + data->>'status' != 'active' + !is_update && !is_auto_ack Deduped at source.
+Timeline segments UNION of: MachineWorkOrder status spans, MachineEvent (mold-change/micro/macro), filled with idle Only way to get continuous coverage.
+Backend changes
+/api/recap/[machineId]/route.ts and /api/recap/summary/route.ts
+goodParts aggregation — stop summing MachineCycle.good_delta. Instead:
+
+// For window [start, end], sum good_parts from WOs that had activity in window
+const wos = await prisma.machineWorkOrder.findMany({
+ where: { machineId, orgId, updatedAt: { gte: start } },
+ select: { workOrderId: true, sku: true, good_parts: true, scrap_parts: true, target_qty: true, status: true, updatedAt: true }
+});
+const goodParts = wos.reduce((s, w) => s + (w.good_parts ?? 0), 0);
+const scrapParts = wos.reduce((s, w) => s + (w.scrap_parts ?? 0), 0);
+Optionally scope to WOs that were RUNNING during the window; but for 24h window this rarely matters.
+
+OEE aggregation — time-weighted average:
+
+const snaps = await prisma.machineKpiSnapshot.findMany({
+ where: { machineId, orgId, ts: { gte: start, lte: end } },
+ orderBy: { ts: 'asc' },
+ select: { ts: true, oee: true, availability: true, performance: true, quality: true }
+});
+
+function weightedAvg(field: 'oee' | 'availability' | 'performance' | 'quality') {
+ if (snaps.length === 0) return null;
+ let totalMs = 0, sum = 0;
+ for (let i = 0; i < snaps.length; i++) {
+ const nextTs = (snaps[i+1]?.ts ?? end).getTime();
+ const dt = Math.max(0, nextTs - snaps[i].ts.getTime());
+ sum += (snaps[i][field] ?? 0) * dt;
+ totalMs += dt;
+ }
+ return totalMs > 0 ? sum / totalMs : null;
+}
+Return null (not 0, not 100) when no snapshots. Frontend renders — for null.
+
+Stops aggregation — filter properly:
+
+const stops = await prisma.machineEvent.findMany({
+ where: {
+ machineId, orgId,
+ ts: { gte: start, lte: end },
+ eventType: { in: ['microstop','macrostop'] },
+ }
+});
+const real = stops.filter(e => {
+ const d = e.data as any;
+ return d?.status !== 'active' && !d?.is_auto_ack && !d?.is_update;
+});
+const stopsCount = real.length;
+const stopsMin = real.reduce((s, e) => s + (((e.data as any)?.stoppage_duration_seconds ?? 0) / 60), 0);
+/api/recap/[machineId]/timeline — MUST include mold-change
+Segment builder in priority order (higher priority wins when overlapping):
+
+mold-change segments (pair active→resolved by incidentKey, duration from data.duration_sec)
+macrostop segments (same pairing)
+microstop segments (merge runs <60s apart into cluster)
+production segments — derived from WO status history, use MachineWorkOrder.status transitions + MachineCycle density (no cycles for >threshold → not production)
+idle gap-fill
+Never return empty array if any event exists in window. "Sin datos" only if literally zero rows in both MachineEvent and MachineCycle for the window.
+
+Merge rules:
+
+Same-type consecutive segments separated by <30s → merge
+Any segment <30s duration, absorb into neighbor
+Return format:
+
+{
+ range: { start, end },
+ segments: Array<{
+ type: 'production' | 'mold-change' | 'macrostop' | 'microstop' | 'idle',
+ startMs, endMs, durationSec,
+ label?: string, // WO id, mold ids, reason
+ workOrderId?: string,
+ sku?: string,
+ reasonLabel?: string,
+ }>,
+ hasData: boolean // false only if literally empty
+}
+Frontend changes
+RecapMachineCard.tsx / Machine detail page / OverviewTimeline.tsx
+All three MUST consume the same endpoint and render from the same shape. Timeline in Machine detail page (app/(app)/machines/[machineId]/MachineDetailClient.tsx) currently queries its own source — refactor to call /api/recap/[machineId]/timeline with range=1h for the small timeline, range=24h for the recap.
+
+"Sin datos" fallback: render only when hasData === false. If timeline has any mold-change or stop segment, render the bar.
+
+Null handling for OEE
+If backend returns oee: null:
+
+
—
+Sin datos de KPI
+Not 0.0%. Not 100%. Dash. User knows "no data" vs. "bad performance".
+
+Reconciling with Home UI live numbers
+Home UI reads live state.activeWorkOrder.goodParts from Node-RED. Recap reads MachineWorkOrder.good_parts from CT DB.
+
+These WILL briefly differ because of outbox lag (cycle POST → DB insert → next recap query). Mitigate:
+
+Cache recap endpoints 30-60s max (shorter than current 2-5 min).
+On recap header, show "Actualizado hace Xs" timestamp so user sees freshness.
+Pi cycle outbox should already be fast (<5s normally). If backlog is persistent, flag it in the UI with a "CT desincronizado" warning (compare MachineHeartbeat.ts to now; if >5min, show amber status).
+Sanity check queries for debugging
+Run on CT to audit one machine:
+
+-- Authoritative WO state (matches Home UI)
+SELECT work_order_id, sku, good_parts, scrap_parts, cycle_count, status, "updatedAt"
+FROM "MachineWorkOrder"
+WHERE "machineId" = ''
+ORDER BY "updatedAt" DESC LIMIT 5;
+
+-- What KPI snapshots exist in last 24h
+SELECT ts, oee, availability, performance, quality
+FROM "MachineKpiSnapshot"
+WHERE "machineId" = '' AND ts > NOW() - INTERVAL '24 hours'
+ORDER BY ts DESC LIMIT 20;
+
+-- Events breakdown
+SELECT "eventType",
+ COUNT(*) AS total,
+ COUNT(*) FILTER (WHERE data->>'status' = 'active') AS active,
+ COUNT(*) FILTER (WHERE (data->>'is_update')::bool) AS updates,
+ COUNT(*) FILTER (WHERE (data->>'is_auto_ack')::bool) AS auto_acks
+FROM "MachineEvent"
+WHERE "machineId" = '' AND ts > NOW() - INTERVAL '24 hours'
+GROUP BY "eventType";
+If MachineWorkOrder.good_parts says 353 and Home UI says 353 but recap says 185 → recap is still using old aggregation.
+If MachineKpiSnapshot count is 0 for last hour → Node-RED isn't sending snapshots (check outbox).
+
+Checklist
+not done
+Recap endpoints use MachineWorkOrder.good_parts not cycle sum
+not done
+OEE uses time-weighted MachineKpiSnapshot avg, returns null when empty
+not done
+Timeline includes mold-change events
+not done
+Machine detail timeline uses same endpoint as recap
+not done
+"Sin datos" fallback only when hasData: false
+not done
+Null OEE renders as —, not 0 or 100
+not done
+Same endpoint feeds recap grid mini timeline + detail full timeline
+not done
+Cache TTL reduced to 30-60s
+not done
+Staleness indicator visible in UI header
+Non-goals: no schema changes, no Node-RED changes, no new ingest endpoints
\ No newline at end of file
diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx
index 358a72a..1555153 100644
--- a/app/(app)/machines/[machineId]/MachineDetailClient.tsx
+++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx
@@ -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(null);
const [cycles, setCycles] = useState([]);
const [thresholds, setThresholds] = useState(null);
- const [activeStoppage, setActiveStoppage] = useState(null);
const [open, setOpen] = useState(null);
const fileInputRef = useRef(null);
const [uploadState, setUploadState] = useState({ 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(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 (
@@ -777,49 +730,56 @@ export default function MachineDetailClient() {
{t("machine.detail.activity.title")}
{t("machine.detail.activity.subtitle")}
- {windowSec}s
+ 1h
- {(["normal", "slow", "microstop", "macrostop"] as const).map((key) => (
-
-
-
{t(BUCKET[key].labelKey)}
+ {(["production", "mold-change", "macrostop", "microstop", "idle"] as const).map((type) => (
+
+
+
+ {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}
+
))}
- 0s
- 1h
+ {timelineLoading ? t("common.loading") : formatTime(startMs, locale)}
+ {formatTime(endMs, locale)}
- {segments.length === 0 ? (
+ {!hasData ? (
{t("machine.detail.activity.noData")}
) : (
- 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 (
);
})
@@ -1106,12 +1066,19 @@ export default function MachineDetailClient() {
OEE
-
{fmtPct(kpi?.oee)}
+ {kpi?.oee == null || Number.isNaN(kpi.oee) ? (
+
—
+ ) : (
+
{fmtPct(kpi?.oee)}
+ )}
{t("machine.detail.kpi.updated", {
time: kpi?.ts ? timeAgo(kpi.ts) : t("common.never"),
})}
+ {kpi?.oee == null || Number.isNaN(kpi.oee) ? (
+
{t("recap.kpi.noData")}
+ ) : null}
@@ -1131,12 +1098,7 @@ export default function MachineDetailClient() {
-
+
{!screenlessMode && (
diff --git a/app/(app)/recap/RecapClient.tsx b/app/(app)/recap/RecapClient.tsx
deleted file mode 100644
index 1b7f932..0000000
--- a/app/(app)/recap/RecapClient.tsx
+++ /dev/null
@@ -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
(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(() => {
- if (initialFilters.shift) return "shift";
- if (initialFilters.start || initialFilters.end) return "custom";
- return "24h";
- });
- const [loading, setLoading] = useState(false);
- const [timeline, setTimeline] = useState(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 (
-
-
-
-
-
{t("recap.title")}
-
- {t("recap.subtitle")} · {new Date(data.range.start).toLocaleString(locale)} - {new Date(data.range.end).toLocaleString(locale)}
-
-
-
-
-
-
-
- {mode === "shift" ? (
-
- ) : null}
-
- {mode === "custom" ? (
- <>
- setCustomStart(event.target.value)}
- className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
- />
- setCustomEnd(event.target.value)}
- className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
- />
- >
- ) : null}
-
-
-
-
- {bannerMold ? (
-
- {t("recap.banner.mold")} {moldStartLabel}
- {moldElapsedMin != null ? ` · ${moldElapsedMin} min` : ""}
-
- ) : null}
- {bannerStop ? (
-
- {t("recap.banner.stopped", { minutes: toMinutesLabel(selectedMachine?.downtime.ongoingStopMin ?? null) })}
-
- ) : null}
-
- {loading ?
{t("common.loading")}
: null}
-
- {timeline ? (
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/app/(app)/recap/RecapGridClient.tsx b/app/(app)/recap/RecapGridClient.tsx
new file mode 100644
index 0000000..e44e999
--- /dev/null
+++ b/app/(app)/recap/RecapGridClient.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { useI18n } from "@/lib/i18n/useI18n";
+import type { RecapMachineStatus, RecapSummaryResponse } from "@/lib/recap/types";
+import RecapMachineCard from "@/components/recap/RecapMachineCard";
+
+type Props = {
+ initialData: RecapSummaryResponse;
+};
+
+function statusLabel(status: RecapMachineStatus, t: (key: string) => string) {
+ if (status === "running") return t("recap.status.running");
+ if (status === "mold-change") return t("recap.status.moldChange");
+ if (status === "stopped") return t("recap.status.stopped");
+ return t("recap.status.offline");
+}
+
+export default function RecapGridClient({ initialData }: Props) {
+ const { t } = useI18n();
+
+ const [data, setData] = useState(initialData);
+ const [loading, setLoading] = useState(false);
+ const [locationFilter, setLocationFilter] = useState("all");
+ const [statusFilter, setStatusFilter] = useState<"all" | RecapMachineStatus>("all");
+ const [nowMs, setNowMs] = useState(() => Date.now());
+
+ useEffect(() => {
+ const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
+ return () => window.clearInterval(timer);
+ }, []);
+
+ useEffect(() => {
+ let alive = true;
+
+ async function refresh() {
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/recap/summary?hours=${data.range.hours}`, { cache: "no-store" });
+ const json = await res.json().catch(() => null);
+ if (!alive || !json || !res.ok) return;
+ setData(json as RecapSummaryResponse);
+ } finally {
+ if (alive) setLoading(false);
+ }
+ }
+
+ const onFocus = () => {
+ void refresh();
+ };
+
+ const interval = window.setInterval(onFocus, 60000);
+ window.addEventListener("focus", onFocus);
+
+ return () => {
+ alive = false;
+ window.clearInterval(interval);
+ window.removeEventListener("focus", onFocus);
+ };
+ }, [data.range.hours]);
+
+ const locationOptions = useMemo(() => {
+ const set = new Set();
+ for (const machine of data.machines) {
+ if (machine.location) set.add(machine.location);
+ }
+ return [...set].sort((a, b) => a.localeCompare(b));
+ }, [data.machines]);
+
+ const filteredMachines = useMemo(() => {
+ return data.machines.filter((machine) => {
+ if (locationFilter !== "all" && machine.location !== locationFilter) return false;
+ if (statusFilter !== "all" && machine.status !== statusFilter) return false;
+ return true;
+ });
+ }, [data.machines, locationFilter, statusFilter]);
+
+ const generatedAtMs = new Date(data.generatedAt).getTime();
+ const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
+
+ return (
+
+
+
+
+
{t("recap.grid.title")}
+
{t("recap.grid.subtitle")}
+ {freshAgeSec != null ? (
+
{t("recap.grid.updatedAgo", { sec: freshAgeSec })}
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ {loading && data.machines.length === 0 ? (
+
+ {Array.from({ length: 6 }).map((_, idx) => (
+
+ ))}
+
+ ) : null}
+
+ {loading && data.machines.length > 0 ? (
+
{t("common.loading")}
+ ) : null}
+
+ {filteredMachines.length === 0 ? (
+
+ {t("recap.grid.empty")}
+
+ ) : (
+
+ {filteredMachines.map((machine) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/(app)/recap/[machineId]/RecapDetailClient.tsx b/app/(app)/recap/[machineId]/RecapDetailClient.tsx
new file mode 100644
index 0000000..8c61968
--- /dev/null
+++ b/app/(app)/recap/[machineId]/RecapDetailClient.tsx
@@ -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(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 (
+
+
+
+
+ {`← ${t("recap.detail.back")}`}
+
+
{machine.name || machineId}
+
{machine.location || t("common.na")}
+ {freshAgeSec != null ? (
+
{t("recap.grid.updatedAgo", { sec: freshAgeSec })}
+ ) : null}
+
+
+
+ {(["24h", "shift", "yesterday", "custom"] as const).map((range) => (
+
+ ))}
+
+
+
+ {selectedRange === "custom" ? (
+
+ setCustomStart(event.target.value)}
+ className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
+ />
+ setCustomEnd(event.target.value)}
+ className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
+ />
+
+
+ ) : null}
+
+ {isPending ?
{t("common.loading")}
: null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(app)/recap/[machineId]/loading.tsx b/app/(app)/recap/[machineId]/loading.tsx
new file mode 100644
index 0000000..78f55f0
--- /dev/null
+++ b/app/(app)/recap/[machineId]/loading.tsx
@@ -0,0 +1,13 @@
+export default function LoadingRecapDetail() {
+ return (
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/(app)/recap/[machineId]/page.tsx b/app/(app)/recap/[machineId]/page.tsx
new file mode 100644
index 0000000..50950b5
--- /dev/null
+++ b/app/(app)/recap/[machineId]/page.tsx
@@ -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>;
+}) {
+ 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 (
+
+ );
+}
diff --git a/app/(app)/recap/loading.tsx b/app/(app)/recap/loading.tsx
new file mode 100644
index 0000000..8dd6573
--- /dev/null
+++ b/app/(app)/recap/loading.tsx
@@ -0,0 +1,12 @@
+export default function LoadingRecapGrid() {
+ return (
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/(app)/recap/page.tsx b/app/(app)/recap/page.tsx
index 08f20e4..f0b6e13 100644
--- a/app/(app)/recap/page.tsx
+++ b/app/(app)/recap/page.tsx
@@ -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>;
-}) {
+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 (
-
- );
+ return ;
}
diff --git a/app/api/recap/[machineId]/route.ts b/app/api/recap/[machineId]/route.ts
new file mode 100644
index 0000000..9a10f5e
--- /dev/null
+++ b/app/api/recap/[machineId]/route.ts
@@ -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",
+ },
+ });
+}
diff --git a/app/api/recap/[machineId]/timeline/route.ts b/app/api/recap/[machineId]/timeline/route.ts
new file mode 100644
index 0000000..4257cbb
--- /dev/null
+++ b/app/api/recap/[machineId]/timeline/route.ts
@@ -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",
+ },
+ });
+}
diff --git a/app/api/recap/summary/route.ts b/app/api/recap/summary/route.ts
new file mode 100644
index 0000000..83696b5
--- /dev/null
+++ b/app/api/recap/summary/route.ts
@@ -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",
+ },
+ });
+}
diff --git a/app/api/recap/timeline/route.ts b/app/api/recap/timeline/route.ts
index 296bebc..387abf9 100644
--- a/app/api/recap/timeline/route.ts
+++ b/app/api/recap/timeline/route.ts
@@ -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 = {
- 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(rows: T[], keyFn: (row: T) => string) {
- const seen = new Set();
- 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) : {};
- const nested = record.data;
- if (typeof nested === "object" && nested && !Array.isArray(nested)) return nested as Record;
- 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, 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) {
- 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;
- 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([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);
+ });
}
diff --git a/components/recap/RecapBanners.tsx b/components/recap/RecapBanners.tsx
new file mode 100644
index 0000000..12cd71c
--- /dev/null
+++ b/components/recap/RecapBanners.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useI18n } from "@/lib/i18n/useI18n";
+
+type Props = {
+ moldChangeStartMs: number | null;
+ offlineForMin: number | null;
+ ongoingStopMin: number | null;
+};
+
+function toInt(value: number | null | undefined) {
+ if (value == null || Number.isNaN(value)) return 0;
+ return Math.max(0, Math.round(value));
+}
+
+export default function RecapBanners({ moldChangeStartMs, offlineForMin, ongoingStopMin }: Props) {
+ const { t, locale } = useI18n();
+
+ const moldStartLabel = moldChangeStartMs
+ ? new Date(moldChangeStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
+ : "--:--";
+
+ return (
+
+ {moldChangeStartMs ? (
+
+ {t("recap.banner.moldChange", { time: moldStartLabel })}
+
+ ) : null}
+
+ {offlineForMin != null && offlineForMin > 10 ? (
+
+ {t("recap.banner.offline", { min: toInt(offlineForMin) })}
+
+ ) : null}
+
+ {ongoingStopMin != null && ongoingStopMin > 0 ? (
+
+ {t("recap.banner.ongoingStop", { min: toInt(ongoingStopMin) })}
+
+ ) : null}
+
+ );
+}
diff --git a/components/recap/RecapDowntimeTop.tsx b/components/recap/RecapDowntimeTop.tsx
index 1286626..25ebc59 100644
--- a/components/recap/RecapDowntimeTop.tsx
+++ b/components/recap/RecapDowntimeTop.tsx
@@ -1,54 +1,32 @@
"use client";
-import { Bar, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, BarChart } from "recharts";
import { useI18n } from "@/lib/i18n/useI18n";
-
-type Row = {
- reasonLabel: string;
- minutes: number;
- count: number;
-};
+import type { RecapDowntimeTopRow } from "@/lib/recap/types";
type Props = {
- rows: Row[];
+ rows: RecapDowntimeTopRow[];
};
export default function RecapDowntimeTop({ rows }: Props) {
const { t } = useI18n();
- const data = rows.slice(0, 3).map((row) => ({ ...row, label: row.reasonLabel.slice(0, 20) }));
return (
-
{t("recap.downtime.title")}
- {data.length === 0 ? (
+
{t("recap.downtime.top")}
+
+ {rows.length === 0 ? (
{t("recap.empty.production")}
) : (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
- {data.map((row) => (
-
-
{row.reasonLabel}
-
- {row.minutes.toFixed(1)} min · {row.count}
-
+
+ {rows.slice(0, 3).map((row) => (
+
+
{row.reasonLabel}
+
+ {row.minutes.toFixed(1)} min · {row.percent.toFixed(1)}%
- ))}
-
- >
+
+ ))}
+
)}
);
diff --git a/components/recap/RecapFullTimeline.tsx b/components/recap/RecapFullTimeline.tsx
new file mode 100644
index 0000000..fdd899b
--- /dev/null
+++ b/components/recap/RecapFullTimeline.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import type { RecapTimelineSegment } from "@/lib/recap/types";
+import {
+ computeWidths,
+ formatDuration,
+ formatTime,
+ LABEL_MIN_WIDTH_PCT,
+ normalizeTimelineSegments,
+ TIMELINE_COLORS,
+} from "@/components/recap/timelineRender";
+import { useI18n } from "@/lib/i18n/useI18n";
+
+type Props = {
+ rangeStart: string;
+ rangeEnd: string;
+ segments: RecapTimelineSegment[];
+ locale: string;
+ hasData?: boolean;
+};
+
+const MIN_SEGMENT_PCT = 1.5;
+
+export default function RecapFullTimeline({ rangeStart, rangeEnd, segments, locale, hasData = true }: Props) {
+ const { t } = useI18n();
+ const startMs = new Date(rangeStart).getTime();
+ const endMs = new Date(rangeEnd).getTime();
+ const totalMs = Math.max(1, endMs - startMs);
+
+ const normalized = normalizeTimelineSegments(segments, startMs, endMs);
+ const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
+
+ return (
+
+
{t("recap.timeline.title")}
+ {!hasData ? (
+
+ {t("recap.timeline.noData")}
+
+ ) : null}
+ {hasData ? (
+
+
+
+ {normalized.map((segment, index) => {
+ const widthPct = widths[index] ?? 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} · ${formatTime(segment.startMs, locale)}-${formatTime(
+ segment.endMs,
+ locale
+ )} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
+
+ return (
+
+ {widthPct > LABEL_MIN_WIDTH_PCT ? segment.label : ""}
+
+ );
+ })}
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/components/recap/RecapKpiRow.tsx b/components/recap/RecapKpiRow.tsx
index e79c434..4c06179 100644
--- a/components/recap/RecapKpiRow.tsx
+++ b/components/recap/RecapKpiRow.tsx
@@ -9,23 +9,26 @@ type Props = {
scrapParts: number;
};
-function fmtPct(v: number | null) {
- if (v == null || Number.isNaN(v)) return "--";
- return `${v.toFixed(1)}%`;
-}
-
export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts }: Props) {
const { t } = useI18n();
const items = [
- { label: t("recap.kpi.oee"), value: fmtPct(oeeAvg), valueClass: "text-emerald-400" },
{ label: t("recap.kpi.good"), value: String(goodParts), valueClass: "text-white" },
- { label: t("recap.kpi.stops"), value: String(totalStops), valueClass: totalStops > 0 ? "text-amber-400" : "text-white" },
- { label: t("recap.kpi.scrap"), value: String(scrapParts), valueClass: scrapParts > 0 ? "text-red-400" : "text-white" },
+ { label: t("recap.kpi.stops"), value: String(totalStops), valueClass: totalStops > 0 ? "text-amber-300" : "text-white" },
+ { label: t("recap.kpi.scrap"), value: String(scrapParts), valueClass: scrapParts > 0 ? "text-red-300" : "text-white" },
];
return (
-
+
+
+
+ {oeeAvg == null || Number.isNaN(oeeAvg) ? "—" : `${oeeAvg.toFixed(1)}%`}
+
+
{t("recap.kpi.oee")}
+ {oeeAvg == null || Number.isNaN(oeeAvg) ? (
+
{t("recap.kpi.noData")}
+ ) : null}
+
{items.map((item) => (
{item.value}
diff --git a/components/recap/RecapMachineCard.tsx b/components/recap/RecapMachineCard.tsx
new file mode 100644
index 0000000..ae7f6a6
--- /dev/null
+++ b/components/recap/RecapMachineCard.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { useI18n } from "@/lib/i18n/useI18n";
+import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
+import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
+
+type Props = {
+ machine: RecapSummaryMachine;
+ rangeStart: string;
+ rangeEnd: string;
+};
+
+const STATUS_DOT: Record
= {
+ running: "bg-emerald-400",
+ "mold-change": "bg-amber-400",
+ stopped: "bg-red-500",
+ offline: "bg-zinc-500",
+};
+
+function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
+ if (status === "running") return t("recap.status.running");
+ if (status === "mold-change") return t("recap.status.moldChange");
+ if (status === "stopped") return t("recap.status.stopped");
+ return t("recap.status.offline");
+}
+
+function toInt(value: number | null | undefined) {
+ if (value == null || Number.isNaN(value)) return 0;
+ return Math.max(0, Math.round(value));
+}
+
+export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
+ const { t, locale } = useI18n();
+ const [timeline, setTimeline] = useState(null);
+ const [nowMs, setNowMs] = useState(() => Date.now());
+
+ 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 staleHeartbeat = machine.lastSeenMs == null ? true : nowMs - machine.lastSeenMs > 5 * 60 * 1000;
+
+ 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(() => {
+ const timer = window.setInterval(() => setNowMs(Date.now()), 60000);
+ return () => window.clearInterval(timer);
+ }, []);
+
+ useEffect(() => {
+ let alive = true;
+
+ async function loadTimeline() {
+ try {
+ const res = await fetch(
+ `/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=30`,
+ { cache: "no-store" }
+ );
+ const json = await res.json().catch(() => null);
+ if (!alive || !res.ok || !json) return;
+ setTimeline(json as RecapTimelineResponse);
+ } catch {
+ }
+ }
+
+ void loadTimeline();
+ const timer = window.setInterval(() => {
+ void loadTimeline();
+ }, 60000);
+
+ return () => {
+ alive = false;
+ window.clearInterval(timer);
+ };
+ }, [machine.machineId]);
+
+ return (
+
+
+
+
{machine.name}
+
{machine.location || t("common.na")}
+
+
+
+ {statusLabel(machine.status, t)}
+
+
+
+
+
{primaryMetric}
+
{t("recap.card.oee")}
+
+ {machine.oee == null ? {t("recap.kpi.noData")}
: null}
+
+ {zeroActivity ? {t("recap.card.noProduction")}
: null}
+
+
+ {t("recap.card.good")}: {machine.goodParts}
+ {t("recap.card.scrap")}: {machine.scrap}
+ {t("recap.card.stops")}: {machine.stopsCount}
+
+
+
+
+
+
+ {machine.moldChange?.active ? (
+
+ {t("recap.card.moldChangeActive", { min: toInt(moldMinutes) })}
+
+ ) : null}
+
+ {machine.offlineForMin != null && machine.offlineForMin > 10 ? (
+
+ {t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
+
+ ) : null}
+ {staleHeartbeat ? (
+
+ {t("recap.card.desynced")}
+
+ ) : null}
+
+ {footerText}
+
+ );
+}
diff --git a/components/recap/RecapMachineStatus.tsx b/components/recap/RecapMachineStatus.tsx
index f8cfed3..be90309 100644
--- a/components/recap/RecapMachineStatus.tsx
+++ b/components/recap/RecapMachineStatus.tsx
@@ -1,44 +1,32 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
-import type { RecapMachine } from "@/lib/recap/types";
type Props = {
- machine: RecapMachine | null;
+ heartbeat: {
+ lastSeenAt: string | null;
+ uptimePct: number | null;
+ connectionStatus: "online" | "offline";
+ };
};
-export default function RecapMachineStatus({ machine }: Props) {
+export default function RecapMachineStatus({ heartbeat }: Props) {
const { t, locale } = useI18n();
- if (!machine) {
- return (
-
-
{t("recap.empty.production")}
-
- );
- }
-
- const isStopped = (machine.downtime.ongoingStopMin ?? 0) > 0;
-
return (
{t("recap.machine.title")}
-
-
- {isStopped ? t("recap.machine.stopped") : t("recap.machine.running")}
-
-
- -
-
- {t("recap.machine.mold")}: {machine.workOrders.moldChangeInProgress ? t("common.yes") : t("common.no")}
+
+ {heartbeat.connectionStatus === "online" ? t("recap.machine.online") : t("recap.machine.offline")}
-
- {t("recap.machine.lastHeartbeat")}: {machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).toLocaleString(locale) : "--"}
+ {t("recap.machine.lastHeartbeat")}: {heartbeat.lastSeenAt ? new Date(heartbeat.lastSeenAt).toLocaleString(locale) : "--"}
-
- {t("recap.machine.uptime")}: {machine.heartbeat.uptimePct == null ? "--" : `${machine.heartbeat.uptimePct.toFixed(1)}%`}
+ {t("recap.machine.uptime")}: {heartbeat.uptimePct == null ? "--" : `${heartbeat.uptimePct.toFixed(1)}%`}
diff --git a/components/recap/RecapMiniTimeline.tsx b/components/recap/RecapMiniTimeline.tsx
new file mode 100644
index 0000000..b9c211d
--- /dev/null
+++ b/components/recap/RecapMiniTimeline.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import type { RecapTimelineSegment } from "@/lib/recap/types";
+import {
+ computeWidths,
+ formatDuration,
+ formatTime,
+ normalizeTimelineSegments,
+ TIMELINE_COLORS,
+} from "@/components/recap/timelineRender";
+import { useI18n } from "@/lib/i18n/useI18n";
+
+type Props = {
+ rangeStart: string;
+ rangeEnd: string;
+ segments: RecapTimelineSegment[];
+ locale: string;
+ muted?: boolean;
+ hasData?: boolean;
+};
+
+const MIN_SEGMENT_PCT = 1.5;
+
+export default function RecapMiniTimeline({
+ rangeStart,
+ rangeEnd,
+ segments,
+ locale,
+ muted = false,
+ hasData = true,
+}: Props) {
+ const { t } = useI18n();
+ const startMs = new Date(rangeStart).getTime();
+ const endMs = new Date(rangeEnd).getTime();
+ const totalMs = Math.max(1, endMs - startMs);
+
+ const normalized = normalizeTimelineSegments(segments, startMs, endMs);
+ const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
+
+ if (!hasData) {
+ return (
+
+ {t("recap.timeline.noData")}
+
+ );
+ }
+
+ if (!normalized.length) {
+ return ;
+ }
+
+ return (
+
+ {normalized.map((segment, index) => {
+ const widthPct = widths[index] ?? 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} · ${formatTime(segment.startMs, locale)}-${formatTime(segment.endMs, locale)} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
+ const color = muted ? "bg-zinc-700 text-zinc-300" : TIMELINE_COLORS[segment.type];
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/components/recap/RecapProductionBySku.tsx b/components/recap/RecapProductionBySku.tsx
index 361aae9..2754091 100644
--- a/components/recap/RecapProductionBySku.tsx
+++ b/components/recap/RecapProductionBySku.tsx
@@ -12,34 +12,37 @@ export default function RecapProductionBySku({ rows }: Props) {
return (
-
{t("recap.production.title")}
+
{t("recap.production.bySku")}
+
{rows.length === 0 ? (
{t("recap.empty.production")}
) : (
-
-
-
Maquina
-
SKU
-
{t("recap.production.good")}
-
{t("recap.production.scrap")}
-
{t("recap.production.target")}
-
{t("recap.production.progress")}
-
- {rows.slice(0, 8).map((row) => {
- const pct = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`;
- return (
-
-
{row.machineName}
-
{row.sku}
-
{row.good}
-
0 ? "text-red-400" : "text-zinc-200"}>{row.scrap}
-
{row.target ?? "--"}
-
- {pct}
-
-
- );
- })}
+
+
+
+
+ | {t("recap.production.sku")} |
+ {t("recap.production.good")} |
+ {t("recap.production.scrap")} |
+ {t("recap.production.target")} |
+ {t("recap.production.progress")} |
+
+
+
+ {rows.slice(0, 10).map((row) => {
+ const progress = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`;
+ return (
+
+ | {row.sku} |
+ {row.good} |
+ 0 ? "text-red-300" : ""}`}>{row.scrap} |
+ {row.target ?? "--"} |
+ {progress} |
+
+ );
+ })}
+
+
)}
diff --git a/components/recap/RecapTimeline.tsx b/components/recap/RecapTimeline.tsx
index b45a984..b2b0234 100644
--- a/components/recap/RecapTimeline.tsx
+++ b/components/recap/RecapTimeline.tsx
@@ -10,13 +10,15 @@ type Props = {
};
const COLORS: Record
= {
- production: "bg-emerald-500 text-emerald-50",
- "mold-change": "bg-blue-400 text-blue-950",
- macrostop: "bg-red-500 text-red-50",
- microstop: "bg-orange-500 text-orange-50",
- "slow-cycle": "bg-amber-500 text-amber-950",
- idle: "bg-zinc-600 text-zinc-100",
+ production: "bg-emerald-500 text-black",
+ "mold-change": "bg-sky-400 text-black",
+ macrostop: "bg-red-500 text-white",
+ microstop: "bg-orange-500 text-black",
+ "slow-cycle": "bg-amber-500 text-black",
+ idle: "bg-zinc-600 text-zinc-300",
};
+const MIN_SEGMENT_PCT = 1.5;
+const LABEL_MIN_PCT = 5;
function fmtTime(valueMs: number, locale: string) {
return new Date(valueMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
@@ -30,54 +32,141 @@ function fmtDuration(startMs: number, endMs: number) {
return `${h}h ${m}m`;
}
+function shouldMergeByType(type: RecapTimelineSegment["type"]) {
+ return type === "macrostop" || type === "microstop" || type === "slow-cycle" || type === "idle";
+}
+
+function normalizeForRender(segments: RecapTimelineSegment[], startMs: number, endMs: number) {
+ const ordered = segments
+ .map((segment) => ({
+ ...segment,
+ startMs: Math.max(startMs, segment.startMs),
+ endMs: Math.min(endMs, segment.endMs),
+ }))
+ .filter((segment) => segment.endMs > segment.startMs)
+ .sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
+
+ const out: RecapTimelineSegment[] = [];
+ let cursor = startMs;
+
+ for (const segment of ordered) {
+ if (segment.startMs > cursor) {
+ const prev = out[out.length - 1];
+ if (prev) {
+ prev.endMs = segment.startMs;
+ } else {
+ out.push({
+ type: "idle",
+ startMs: cursor,
+ endMs: segment.startMs,
+ durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
+ label: "Idle",
+ });
+ }
+ }
+
+ const normalizedStart = Math.max(cursor, segment.startMs);
+ const normalizedEnd = Math.min(endMs, segment.endMs);
+ if (normalizedEnd <= normalizedStart) continue;
+
+ const normalizedSegment: RecapTimelineSegment = {
+ ...segment,
+ startMs: normalizedStart,
+ endMs: normalizedEnd,
+ };
+ const prev = out[out.length - 1];
+
+ if (
+ prev &&
+ prev.type === normalizedSegment.type &&
+ shouldMergeByType(prev.type) &&
+ prev.endMs === normalizedSegment.startMs
+ ) {
+ prev.endMs = normalizedSegment.endMs;
+ } else {
+ out.push(normalizedSegment);
+ }
+ cursor = normalizedEnd;
+ if (cursor >= endMs) break;
+ }
+
+ if (cursor < endMs) {
+ const prev = out[out.length - 1];
+ if (prev) {
+ prev.endMs = endMs;
+ } else {
+ out.push({
+ type: "idle",
+ startMs: cursor,
+ endMs,
+ durationSec: Math.max(0, Math.trunc((endMs - cursor) / 1000)),
+ label: "Idle",
+ });
+ }
+ }
+
+ return out.filter((segment) => segment.endMs > segment.startMs);
+}
+
+function computeWidths(segments: RecapTimelineSegment[], totalMs: number, minPct: number) {
+ if (!segments.length) return [];
+ const base = segments.map((segment) => ((segment.endMs - segment.startMs) / totalMs) * 100);
+ const effectiveMin = Math.min(minPct, 100 / segments.length);
+ let widths = base.map((pct) => Math.max(pct, effectiveMin));
+
+ const sum = widths.reduce((acc, value) => acc + value, 0);
+ if (sum > 100) {
+ const overflow = sum - 100;
+ const slacks = widths.map((value) => Math.max(0, value - effectiveMin));
+ const totalSlack = slacks.reduce((acc, value) => acc + value, 0);
+ if (totalSlack > 0) {
+ widths = widths.map((value, index) => value - (overflow * slacks[index]) / totalSlack);
+ } else {
+ const scale = 100 / sum;
+ widths = widths.map((value) => value * scale);
+ }
+ } else if (sum < 100) {
+ const deficit = 100 - sum;
+ const totalBase = base.reduce((acc, value) => acc + (value > 0 ? value : 1), 0);
+ widths = widths.map((value, index) => value + (deficit * (base[index] > 0 ? base[index] : 1)) / totalBase);
+ }
+
+ const rounded = widths.map((value) => Number(value.toFixed(4)));
+ const roundedSum = rounded.reduce((acc, value) => acc + value, 0);
+ const delta = Number((100 - roundedSum).toFixed(4));
+ if (rounded.length > 0) {
+ rounded[rounded.length - 1] = Number(Math.max(0, rounded[rounded.length - 1] + delta).toFixed(4));
+ }
+ return rounded;
+}
+
export default function RecapTimeline({ rangeStart, rangeEnd, segments, locale }: Props) {
const startMs = new Date(rangeStart).getTime();
const endMs = new Date(rangeEnd).getTime();
const totalMs = Math.max(1, endMs - startMs);
-
- const bars: RecapTimelineSegment[] = [];
- const dots: Array<{ leftPct: number; segment: RecapTimelineSegment }> = [];
-
- for (const segment of segments) {
- const widthPct = ((segment.endMs - segment.startMs) / totalMs) * 100;
- const leftPct = ((segment.startMs - startMs) / totalMs) * 100;
- if (widthPct < 1) {
- if (segment.type !== "idle" && leftPct > 0.5 && leftPct < 99.5) {
- dots.push({ leftPct, segment });
- }
- } else {
- bars.push(segment);
- }
- }
+ const normalized = normalizeForRender(segments, startMs, endMs);
+ const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
return (
Timeline 24h
-
-
- {bars.map((segment) => {
- const widthPct = ((segment.endMs - segment.startMs) / totalMs) * 100;
- const title = `${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
- return (
-
- {widthPct >= 6 ? segment.label : ""}
-
- );
- })}
-
- {dots.map(({ leftPct, segment }) => (
-
- ))}
+
+ {normalized.map((segment, index) => {
+ const widthPct = widths[index] ?? 0;
+ const title = `${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
+ return (
+
+ {widthPct > LABEL_MIN_PCT ? segment.label : ""}
+
+ );
+ })}
);
diff --git a/components/recap/RecapWorkOrders.tsx b/components/recap/RecapWorkOrders.tsx
new file mode 100644
index 0000000..2f65ec7
--- /dev/null
+++ b/components/recap/RecapWorkOrders.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useI18n } from "@/lib/i18n/useI18n";
+import type { RecapWorkOrders as RecapWorkOrdersType } from "@/lib/recap/types";
+
+type Props = {
+ workOrders: RecapWorkOrdersType;
+};
+
+export default function RecapWorkOrders({ workOrders }: Props) {
+ const { t, locale } = useI18n();
+
+ return (
+
+
{t("recap.workOrders.title")}
+
+
+
+
{t("recap.workOrders.completed")}
+ {workOrders.completed.length === 0 ? (
+
{t("recap.workOrders.none")}
+ ) : (
+
+ {workOrders.completed.slice(0, 6).map((row) => (
+
+
{row.id}
+
{t("recap.workOrders.sku")}: {row.sku || "--"}
+
{t("recap.workOrders.goodParts")}: {row.goodParts}
+
{t("recap.workOrders.duration")}: {row.durationHrs.toFixed(2)}h
+
+ ))}
+
+ )}
+
+
+
+
{t("recap.workOrders.active")}
+ {!workOrders.active ? (
+
{t("recap.workOrders.none")}
+ ) : (
+
+
{workOrders.active.id}
+
{t("recap.workOrders.sku")}: {workOrders.active.sku || "--"}
+
+
+ {t("recap.workOrders.startedAt")}: {workOrders.active.startedAt ? new Date(workOrders.active.startedAt).toLocaleString(locale) : "--"}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/components/recap/timelineRender.ts b/components/recap/timelineRender.ts
new file mode 100644
index 0000000..3497526
--- /dev/null
+++ b/components/recap/timelineRender.ts
@@ -0,0 +1,150 @@
+import type { RecapTimelineSegment } from "@/lib/recap/types";
+
+export const TIMELINE_COLORS: Record
= {
+ production: "bg-emerald-500 text-black",
+ "mold-change": "bg-sky-400 text-black",
+ macrostop: "bg-red-500 text-white",
+ microstop: "bg-orange-500 text-black",
+ "slow-cycle": "bg-orange-500 text-black",
+ idle: "bg-zinc-700 text-zinc-300",
+};
+
+export const LABEL_MIN_WIDTH_PCT = 5;
+
+export function formatTime(valueMs: number, locale: string) {
+ return new Date(valueMs).toLocaleTimeString(locale, {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export function formatDuration(startMs: number, endMs: number) {
+ const totalMin = Math.max(0, Math.round((endMs - startMs) / 60000));
+ if (totalMin < 60) return `${totalMin}m`;
+ const h = Math.floor(totalMin / 60);
+ const m = totalMin % 60;
+ return `${h}h ${m}m`;
+}
+
+export function normalizeTimelineSegments(
+ segments: RecapTimelineSegment[],
+ rangeStartMs: number,
+ rangeEndMs: number
+) {
+ const ordered = [...segments]
+ .map((segment) => ({
+ ...segment,
+ startMs: Math.max(rangeStartMs, segment.startMs),
+ endMs: Math.min(rangeEndMs, segment.endMs),
+ }))
+ .filter((segment) => segment.endMs > segment.startMs)
+ .sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
+
+ const out: RecapTimelineSegment[] = [];
+ let cursor = rangeStartMs;
+
+ for (const segment of ordered) {
+ if (segment.startMs > cursor) {
+ out.push({
+ type: "idle",
+ startMs: cursor,
+ endMs: segment.startMs,
+ durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
+ label: "Idle",
+ });
+ }
+
+ const startMs = Math.max(cursor, segment.startMs);
+ const endMs = Math.min(rangeEndMs, segment.endMs);
+ if (endMs <= startMs) continue;
+
+ if (segment.type === "production") {
+ out.push({
+ type: "production",
+ startMs,
+ endMs,
+ durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
+ workOrderId: segment.workOrderId,
+ sku: segment.sku,
+ label: segment.label,
+ });
+ } else if (segment.type === "mold-change") {
+ out.push({
+ type: "mold-change",
+ startMs,
+ endMs,
+ fromMoldId: segment.fromMoldId,
+ toMoldId: segment.toMoldId,
+ durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
+ label: segment.label,
+ });
+ } else if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
+ out.push({
+ type: segment.type === "slow-cycle" ? "microstop" : segment.type,
+ startMs,
+ endMs,
+ reason: segment.reason,
+ reasonLabel: segment.reasonLabel ?? segment.reason,
+ durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
+ label: segment.label,
+ });
+ } else {
+ out.push({
+ type: "idle",
+ startMs,
+ endMs,
+ durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
+ label: segment.label,
+ });
+ }
+
+ cursor = endMs;
+ if (cursor >= rangeEndMs) break;
+ }
+
+ if (cursor < rangeEndMs) {
+ out.push({
+ type: "idle",
+ startMs: cursor,
+ endMs: rangeEndMs,
+ durationSec: Math.max(0, Math.trunc((rangeEndMs - cursor) / 1000)),
+ label: "Idle",
+ });
+ }
+
+ return out;
+}
+
+export function computeWidths(segments: RecapTimelineSegment[], totalMs: number, minPct: number) {
+ if (!segments.length) return [];
+ const base = segments.map((segment) => ((segment.endMs - segment.startMs) / totalMs) * 100);
+ const effectiveMin = Math.min(minPct, 100 / segments.length);
+ let widths = base.map((pct) => Math.max(pct, effectiveMin));
+
+ const sum = widths.reduce((acc, value) => acc + value, 0);
+ if (sum > 100) {
+ const overflow = sum - 100;
+ const slacks = widths.map((value) => Math.max(0, value - effectiveMin));
+ const totalSlack = slacks.reduce((acc, value) => acc + value, 0);
+ if (totalSlack > 0) {
+ widths = widths.map((value, index) => value - (overflow * slacks[index]) / totalSlack);
+ } else {
+ const scale = 100 / sum;
+ widths = widths.map((value) => value * scale);
+ }
+ } else if (sum < 100) {
+ const deficit = 100 - sum;
+ const totalBase = base.reduce((acc, value) => acc + (value > 0 ? value : 1), 0);
+ widths = widths.map(
+ (value, index) => value + (deficit * (base[index] > 0 ? base[index] : 1)) / totalBase
+ );
+ }
+
+ const rounded = widths.map((value) => Number(value.toFixed(4)));
+ const roundedSum = rounded.reduce((acc, value) => acc + value, 0);
+ const delta = Number((100 - roundedSum).toFixed(4));
+ if (rounded.length > 0) {
+ rounded[rounded.length - 1] = Number(Math.max(0, rounded[rounded.length - 1] + delta).toFixed(4));
+ }
+ return rounded;
+}
diff --git a/lib/i18n/en.json b/lib/i18n/en.json
index ffd0c32..09a5d81 100644
--- a/lib/i18n/en.json
+++ b/lib/i18n/en.json
@@ -111,26 +111,55 @@
"overview.recap.cta": "Open daily recap",
"recap.title": "Recap",
"recap.subtitle": "Last 24h",
+ "recap.grid.title": "Machine recap",
+ "recap.grid.subtitle": "Last 24h · click to open details",
+ "recap.grid.updatedAgo": "Updated {sec}s ago",
+ "recap.grid.empty": "No machines match the current filters.",
+ "recap.detail.back": "All machines",
"recap.allMachines": "All machines",
+ "recap.filter.allLocations": "All locations",
+ "recap.filter.allStatuses": "All statuses",
+ "recap.status.running": "Running",
+ "recap.status.moldChange": "Mold change",
+ "recap.status.stopped": "Stopped",
+ "recap.status.offline": "Offline",
+ "recap.range.24h": "24h",
"recap.range.shift": "Shift",
- "recap.range.custom": "Custom range",
+ "recap.range.shiftCurrent": "Current shift",
+ "recap.range.yesterday": "Yesterday",
+ "recap.range.custom": "Custom",
+ "recap.range.apply": "Apply",
"recap.shift.1": "Shift 1",
"recap.shift.2": "Shift 2",
"recap.shift.3": "Shift 3",
- "recap.kpi.oee": "Avg OEE",
+ "recap.kpi.oee": "OEE",
+ "recap.kpi.noData": "No KPI data",
"recap.kpi.good": "Good parts",
- "recap.kpi.stops": "Total stops",
+ "recap.kpi.stops": "Total stops (min)",
"recap.kpi.scrap": "Scrap",
+ "recap.card.oee": "OEE",
+ "recap.card.good": "Good parts",
+ "recap.card.scrap": "Scrap",
+ "recap.card.stops": "Stops",
+ "recap.card.noProduction": "No production",
+ "recap.card.lastActivity": "Last activity {min} min ago",
+ "recap.card.activeWorkOrder": "Active WO: {id}",
+ "recap.card.moldChangeActive": "Mold change in progress · {min}m",
+ "recap.card.desynced": "CT desynchronized",
"recap.production.title": "Production by SKU",
+ "recap.production.bySku": "Production by SKU",
+ "recap.production.sku": "SKU",
"recap.production.good": "Good",
"recap.production.scrap": "Scrap",
"recap.production.target": "Target",
- "recap.production.progress": "Progress",
+ "recap.production.progress": "Progress%",
"recap.downtime.title": "Top downtime",
+ "recap.downtime.top": "Top stops",
"recap.workOrders.title": "Work orders",
"recap.workOrders.active": "Active",
"recap.workOrders.completed": "Completed",
"recap.workOrders.none": "No production recorded",
+ "recap.workOrders.sku": "SKU",
"recap.workOrders.startedAt": "Started",
"recap.workOrders.goodParts": "Good parts",
"recap.workOrders.duration": "Duration",
@@ -138,10 +167,22 @@
"recap.machine.running": "Running",
"recap.machine.stopped": "Stopped",
"recap.machine.mold": "Mold change",
+ "recap.machine.online": "Connected",
+ "recap.machine.offline": "Disconnected",
"recap.machine.lastHeartbeat": "Last heartbeat",
"recap.machine.uptime": "Uptime",
"recap.banner.mold": "Mold change in progress since",
+ "recap.banner.moldChange": "Mold change in progress since {time}",
+ "recap.banner.offline": "No signal for {min} min",
+ "recap.banner.ongoingStop": "Machine stopped for {min} min",
"recap.banner.stopped": "Machine stopped for {minutes} min",
+ "recap.timeline.title": "24h timeline",
+ "recap.timeline.noData": "No timeline data",
+ "recap.timeline.type.production": "Production",
+ "recap.timeline.type.moldChange": "Mold change",
+ "recap.timeline.type.macrostop": "Macrostop",
+ "recap.timeline.type.microstop": "Microstop",
+ "recap.timeline.type.idle": "Idle",
"recap.empty.production": "No production recorded",
"machines.title": "Machines",
"machines.subtitle": "Select a machine to view live KPIs.",
diff --git a/lib/i18n/es-MX.json b/lib/i18n/es-MX.json
index 7323248..f1a055a 100644
--- a/lib/i18n/es-MX.json
+++ b/lib/i18n/es-MX.json
@@ -111,37 +111,78 @@
"overview.recap.cta": "Abrir resumen diario",
"recap.title": "Resumen",
"recap.subtitle": "Últimas 24h",
+ "recap.grid.title": "Resumen de máquinas",
+ "recap.grid.subtitle": "Últimas 24h · click para ver detalle",
+ "recap.grid.updatedAgo": "Actualizado hace {sec}s",
+ "recap.grid.empty": "No hay máquinas que coincidan con los filtros.",
+ "recap.detail.back": "Todas las máquinas",
"recap.allMachines": "Todas las máquinas",
+ "recap.filter.allLocations": "Todas las ubicaciones",
+ "recap.filter.allStatuses": "Todos los estados",
+ "recap.status.running": "En marcha",
+ "recap.status.moldChange": "Cambio de molde",
+ "recap.status.stopped": "Detenida",
+ "recap.status.offline": "Sin señal",
+ "recap.range.24h": "24h",
"recap.range.shift": "Turno",
- "recap.range.custom": "Rango personalizado",
+ "recap.range.shiftCurrent": "Turno actual",
+ "recap.range.yesterday": "Ayer",
+ "recap.range.custom": "Personalizado",
+ "recap.range.apply": "Aplicar",
"recap.shift.1": "Turno 1",
"recap.shift.2": "Turno 2",
"recap.shift.3": "Turno 3",
- "recap.kpi.oee": "OEE prom",
- "recap.kpi.good": "Piezas buenas",
- "recap.kpi.stops": "Paros totales",
+ "recap.kpi.oee": "OEE",
+ "recap.kpi.noData": "Sin datos de KPI",
+ "recap.kpi.good": "Buenas",
+ "recap.kpi.stops": "Paros totales (min)",
"recap.kpi.scrap": "Scrap",
+ "recap.card.oee": "OEE",
+ "recap.card.good": "Piezas buenas",
+ "recap.card.scrap": "Scrap",
+ "recap.card.stops": "Paros",
+ "recap.card.noProduction": "Sin producción",
+ "recap.card.lastActivity": "Última actividad hace {min} min",
+ "recap.card.activeWorkOrder": "WO activa: {id}",
+ "recap.card.moldChangeActive": "Cambio de molde en curso · {min}m",
+ "recap.card.desynced": "CT desincronizado",
"recap.production.title": "Producción por SKU",
+ "recap.production.bySku": "Producción por SKU",
+ "recap.production.sku": "SKU",
"recap.production.good": "Buenas",
"recap.production.scrap": "Scrap",
"recap.production.target": "Meta",
- "recap.production.progress": "Avance",
+ "recap.production.progress": "Avance%",
"recap.downtime.title": "Top downtime",
+ "recap.downtime.top": "Top paros",
"recap.workOrders.title": "Órdenes de trabajo",
"recap.workOrders.active": "Activa",
"recap.workOrders.completed": "Completadas",
"recap.workOrders.none": "Sin producción registrada",
+ "recap.workOrders.sku": "SKU",
"recap.workOrders.startedAt": "Inicio",
"recap.workOrders.goodParts": "Buenas",
"recap.workOrders.duration": "Duración",
- "recap.machine.title": "Estado de máquina",
+ "recap.machine.title": "Estado máquina",
"recap.machine.running": "En marcha",
"recap.machine.stopped": "Detenida",
"recap.machine.mold": "Cambio de molde",
+ "recap.machine.online": "Conectada",
+ "recap.machine.offline": "Sin conexión",
"recap.machine.lastHeartbeat": "Último heartbeat",
"recap.machine.uptime": "Uptime",
"recap.banner.mold": "Cambio de molde en curso desde",
+ "recap.banner.moldChange": "Cambio de molde en curso desde {time}",
+ "recap.banner.offline": "Sin señal hace {min} min",
+ "recap.banner.ongoingStop": "Máquina detenida hace {min} min",
"recap.banner.stopped": "Máquina detenida hace {minutes} min",
+ "recap.timeline.title": "Timeline 24h",
+ "recap.timeline.noData": "Sin datos de línea de tiempo",
+ "recap.timeline.type.production": "Producción",
+ "recap.timeline.type.moldChange": "Cambio de molde",
+ "recap.timeline.type.macrostop": "Macroparo",
+ "recap.timeline.type.microstop": "Microparo",
+ "recap.timeline.type.idle": "Idle",
"recap.empty.production": "Sin producción registrada",
"machines.title": "Máquinas",
"machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.",
diff --git a/lib/recap/getRecapData.ts b/lib/recap/getRecapData.ts
index 8a32209..d09b2ee 100644
--- a/lib/recap/getRecapData.ts
+++ b/lib/recap/getRecapData.ts
@@ -1,4 +1,5 @@
import { unstable_cache } from "next/cache";
+import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
import type { RecapMachine, RecapQuery, RecapResponse } from "@/lib/recap/types";
@@ -25,8 +26,9 @@ const WEEKDAY_KEY_MAP: Record = {
const STOP_TYPES = new Set(["microstop", "macrostop"]);
const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]);
-const CACHE_TTL_SEC = 180;
+const CACHE_TTL_SEC = 60;
const MOLD_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000;
+let workOrderCountersAvailable: boolean | null = null;
function safeNum(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -37,6 +39,17 @@ function safeNum(value: unknown) {
return null;
}
+function safeBool(value: unknown) {
+ if (typeof value === "boolean") return value;
+ if (typeof value === "number") return value !== 0;
+ if (typeof value === "string") {
+ const normalized = value.trim().toLowerCase();
+ if (!normalized) return false;
+ return normalized === "true" || normalized === "1" || normalized === "yes";
+ }
+ return false;
+}
+
function normalizeToken(value: unknown) {
return String(value ?? "").trim();
}
@@ -194,6 +207,14 @@ function eventStatus(data: unknown) {
return String(inner.status ?? "").trim().toLowerCase();
}
+function isRealStopEvent(data: unknown) {
+ const inner = extractEventData(data);
+ const status = String(inner.status ?? "").trim().toLowerCase();
+ const isUpdate = safeBool(inner.is_update ?? inner.isUpdate);
+ const isAutoAck = safeBool(inner.is_auto_ack ?? inner.isAutoAck);
+ return status !== "active" && !isUpdate && !isAutoAck;
+}
+
function eventIncidentKey(data: unknown, eventType: string, ts: Date) {
const inner = extractEventData(data);
const direct = String(inner.incidentKey ?? inner.incident_key ?? "").trim();
@@ -208,9 +229,75 @@ function moldStartMs(data: unknown, fallbackTs: Date) {
return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime());
}
-function avg(sum: number, count: number) {
- if (!count) return null;
- return round2(sum / count);
+type WorkOrderCounterColumnRow = {
+ column_name: string;
+};
+
+type WorkOrderCounterRow = {
+ machineId: string;
+ workOrderId: string;
+ sku: string | null;
+ targetQty: number | null;
+ status: string;
+ createdAt: Date;
+ updatedAt: Date;
+ goodParts: number;
+ scrapParts: number;
+ cycleCount: number;
+};
+
+async function loadWorkOrderCounterRows(params: {
+ orgId: string;
+ machineIds: string[];
+ start: Date;
+ end: Date;
+}) {
+ if (!params.machineIds.length) return [] as WorkOrderCounterRow[];
+
+ try {
+ if (workOrderCountersAvailable == null) {
+ const columns = await prisma.$queryRaw`
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'machine_work_orders'
+ AND column_name IN ('good_parts', 'scrap_parts', 'cycle_count')
+ `;
+ const availableColumns = new Set(columns.map((row) => row.column_name));
+ workOrderCountersAvailable =
+ availableColumns.has("good_parts") &&
+ availableColumns.has("scrap_parts") &&
+ availableColumns.has("cycle_count");
+ }
+
+ if (!workOrderCountersAvailable) {
+ return null;
+ }
+
+ const machineIdList = Prisma.join(params.machineIds.map((id) => Prisma.sql`${id}`));
+ const rows = await prisma.$queryRaw(Prisma.sql`
+ SELECT
+ "machineId",
+ "workOrderId",
+ sku,
+ "targetQty",
+ status,
+ "createdAt",
+ "updatedAt",
+ COALESCE(good_parts, 0)::int AS "goodParts",
+ COALESCE(scrap_parts, 0)::int AS "scrapParts",
+ COALESCE(cycle_count, 0)::int AS "cycleCount"
+ FROM "machine_work_orders"
+ WHERE "orgId" = ${params.orgId}
+ AND "machineId" IN (${machineIdList})
+ AND "updatedAt" >= ${params.start}
+ AND "updatedAt" <= ${params.end}
+ `);
+
+ return rows;
+ } catch {
+ return null;
+ }
}
export function parseRecapQuery(input: {
@@ -250,7 +337,7 @@ async function computeRecap(params: Required> & {
const machineIds = machines.map((m) => m.id);
const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS);
- const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
+ const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, workOrderCounterRowsRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
await Promise.all([
prisma.orgSettings.findUnique({
where: { orgId: params.orgId },
@@ -283,6 +370,7 @@ async function computeRecap(params: Required> & {
machineId: { in: machineIds },
ts: { gte: params.start, lte: params.end },
},
+ orderBy: [{ machineId: "asc" }, { ts: "asc" }],
select: {
machineId: true,
ts: true,
@@ -344,6 +432,12 @@ async function computeRecap(params: Required> & {
updatedAt: true,
},
}),
+ loadWorkOrderCounterRows({
+ orgId: params.orgId,
+ machineIds,
+ start: params.start,
+ end: params.end,
+ }),
prisma.machineHeartbeat.findMany({
where: {
orgId: params.orgId,
@@ -412,6 +506,7 @@ async function computeRecap(params: Required> & {
const eventsByMachine = new Map();
const reasonsByMachine = new Map();
const workOrdersByMachine = new Map();
+ const workOrderCountersByMachine = new Map();
const hbRangeByMachine = new Map();
const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row]));
const moldEventsByMachine = new Map();
@@ -446,6 +541,12 @@ async function computeRecap(params: Required> & {
workOrdersByMachine.set(row.machineId, list);
}
+ for (const row of workOrderCounterRowsRaw ?? []) {
+ const list = workOrderCountersByMachine.get(row.machineId) ?? [];
+ list.push(row);
+ workOrderCountersByMachine.set(row.machineId, list);
+ }
+
for (const row of hbRange) {
const list = hbRangeByMachine.get(row.machineId) ?? [];
list.push(row);
@@ -464,6 +565,7 @@ async function computeRecap(params: Required> & {
const machineEvents = eventsByMachine.get(machine.id) ?? [];
const machineReasons = reasonsByMachine.get(machine.id) ?? [];
const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? [];
+ const machineWorkOrderCounters = workOrderCountersByMachine.get(machine.id) ?? [];
const machineHbRange = hbRangeByMachine.get(machine.id) ?? [];
const latestHb = hbLatestByMachine.get(machine.id) ?? null;
const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? [];
@@ -660,7 +762,63 @@ async function computeRecap(params: Required> & {
}
if (latestTelemetry?.sku) ensureSkuRow(latestTelemetry.sku);
- const bySku = [...skuMap.values()]
+ const hasAuthoritativeWorkOrderCounters = machineWorkOrderCounters.length > 0;
+ const authoritativeWorkOrderProgress = new Map<
+ string,
+ { goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null }
+ >();
+ const authoritativeSkuMap = new Map();
+ let authoritativeGoodParts = 0;
+ let authoritativeScrapParts = 0;
+ let authoritativeCycleCount = 0;
+
+ if (hasAuthoritativeWorkOrderCounters) {
+ for (const row of machineWorkOrderCounters) {
+ const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0));
+ const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0));
+ const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0));
+ const skuToken = normalizeToken(row.sku) || "N/A";
+ const skuTokenKey = skuKey(skuToken);
+ const target = safeNum(row.targetQty);
+
+ const skuAgg = authoritativeSkuMap.get(skuTokenKey) ?? {
+ machineName: machine.name,
+ sku: skuToken,
+ good: 0,
+ scrap: 0,
+ target: target != null && target > 0 ? Math.max(0, Math.trunc(target)) : null,
+ };
+ skuAgg.good += safeGood;
+ skuAgg.scrap += safeScrap;
+ if (target != null && target > 0) {
+ skuAgg.target = (skuAgg.target ?? 0) + Math.max(0, Math.trunc(target));
+ }
+ authoritativeSkuMap.set(skuTokenKey, skuAgg);
+
+ authoritativeGoodParts += safeGood;
+ authoritativeScrapParts += safeScrap;
+ authoritativeCycleCount += safeCycleCount;
+
+ const woKey = workOrderKey(row.workOrderId);
+ if (!woKey) continue;
+
+ const progress = authoritativeWorkOrderProgress.get(woKey) ?? {
+ goodParts: 0,
+ scrapParts: 0,
+ cycleCount: 0,
+ firstTs: null,
+ lastTs: null,
+ };
+ progress.goodParts += safeGood;
+ progress.scrapParts += safeScrap;
+ progress.cycleCount += safeCycleCount;
+ if (!progress.firstTs || row.createdAt < progress.firstTs) progress.firstTs = row.createdAt;
+ if (!progress.lastTs || row.updatedAt > progress.lastTs) progress.lastTs = row.updatedAt;
+ authoritativeWorkOrderProgress.set(woKey, progress);
+ }
+ }
+
+ const fallbackBySku = [...skuMap.values()]
.map((row) => {
const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null;
const produced = row.good + row.scrap;
@@ -676,44 +834,52 @@ async function computeRecap(params: Required> & {
})
.sort((a, b) => b.good - a.good);
- let oeeSum = 0;
- let oeeCount = 0;
- let availabilitySum = 0;
- let availabilityCount = 0;
- let performanceSum = 0;
- let performanceCount = 0;
- let qualitySum = 0;
- let qualityCount = 0;
+ const authoritativeBySku = [...authoritativeSkuMap.values()]
+ .map((row) => {
+ const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null;
+ const produced = row.good + row.scrap;
+ const progressPct = target && target > 0 ? round2((produced / target) * 100) : null;
+ return {
+ machineName: row.machineName,
+ sku: row.sku,
+ good: row.good,
+ scrap: row.scrap,
+ target,
+ progressPct,
+ };
+ })
+ .sort((a, b) => b.good - a.good);
- for (const kpi of dedupedKpis) {
- const oee = safeNum(kpi.oee);
- const availability = safeNum(kpi.availability);
- const performance = safeNum(kpi.performance);
- const quality = safeNum(kpi.quality);
-
- if (oee != null) {
- oeeSum += oee;
- oeeCount += 1;
- }
- if (availability != null) {
- availabilitySum += availability;
- availabilityCount += 1;
- }
- if (performance != null) {
- performanceSum += performance;
- performanceCount += 1;
- }
- if (quality != null) {
- qualitySum += quality;
- qualityCount += 1;
- }
+ const bySku = hasAuthoritativeWorkOrderCounters ? authoritativeBySku : fallbackBySku;
+ if (hasAuthoritativeWorkOrderCounters) {
+ goodParts = authoritativeGoodParts;
+ scrapParts = authoritativeScrapParts;
}
+ const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
+ const weightedAvg = (field: "oee" | "availability" | "performance" | "quality") => {
+ if (!sortedKpis.length) return null;
+ let totalMs = 0;
+ let weightedSum = 0;
+
+ for (let i = 0; i < sortedKpis.length; i += 1) {
+ const current = sortedKpis[i];
+ const nextTsMs = (sortedKpis[i + 1]?.ts ?? params.end).getTime();
+ const dt = Math.max(0, nextTsMs - current.ts.getTime());
+ if (dt <= 0) continue;
+ weightedSum += (safeNum(current[field]) ?? 0) * dt;
+ totalMs += dt;
+ }
+
+ return totalMs > 0 ? round2(weightedSum / totalMs) : null;
+ };
+
let stopDurSecFromEvents = 0;
let stopsCount = 0;
for (const event of machineEvents) {
const type = String(event.eventType || "").toLowerCase();
if (!STOP_TYPES.has(type)) continue;
+ if (!isRealStopEvent(event.data)) continue;
stopsCount += 1;
stopDurSecFromEvents += eventDurationSec(event.data);
}
@@ -759,12 +925,14 @@ async function computeRecap(params: Required> & {
.filter((wo) => String(wo.status).toUpperCase() === "COMPLETED")
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
.map((wo) => {
- const progress = rangeByWorkOrder.get(workOrderKey(wo.workOrderId)) ?? {
+ const fallbackProgress = rangeByWorkOrder.get(workOrderKey(wo.workOrderId)) ?? {
goodParts: 0,
scrapParts: 0,
firstTs: null,
lastTs: null,
};
+ const authoritativeProgress = authoritativeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? null;
+ const progress = authoritativeProgress ?? fallbackProgress;
const durationHrs =
progress.firstTs && progress.lastTs
? round2((progress.lastTs.getTime() - progress.firstTs.getTime()) / 3600000)
@@ -788,24 +956,37 @@ async function computeRecap(params: Required> & {
const activeWorkOrderSku =
normalizeToken(latestTelemetry?.sku) || normalizeToken(activeWo?.sku) || null;
const activeWorkOrderKey = workOrderKey(activeWorkOrderId);
+ const authoritativeActiveWo =
+ activeWorkOrderKey && hasAuthoritativeWorkOrderCounters
+ ? machineWorkOrderCounters.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ?? null
+ : null;
const activeTargetSource =
activeWorkOrderKey
- ? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ?? activeWo
+ ? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ??
+ activeWo ??
+ authoritativeActiveWo
: activeWo;
let activeProgressPct: number | null = null;
let activeStartedAt: string | null = null;
if (activeWorkOrderId) {
const rangeProgress = activeWorkOrderKey ? rangeByWorkOrder.get(activeWorkOrderKey) : null;
+ const authoritativeProgress = activeWorkOrderKey
+ ? authoritativeWorkOrderProgress.get(activeWorkOrderKey) ?? null
+ : null;
const cumulativeProgress = activeWorkOrderKey ? kpiLatestByWorkOrder.get(activeWorkOrderKey) : null;
- const producedForProgress = cumulativeProgress
- ? cumulativeProgress.good + cumulativeProgress.scrap
- : (rangeProgress?.goodParts ?? 0) + (rangeProgress?.scrapParts ?? 0);
+ const producedForProgress = authoritativeProgress
+ ? authoritativeProgress.goodParts + authoritativeProgress.scrapParts
+ : cumulativeProgress
+ ? cumulativeProgress.good + cumulativeProgress.scrap
+ : (rangeProgress?.goodParts ?? 0) + (rangeProgress?.scrapParts ?? 0);
const targetQty = safeNum(activeTargetSource?.targetQty);
if (targetQty && targetQty > 0) {
activeProgressPct = round2((producedForProgress / targetQty) * 100);
}
- activeStartedAt = toIso(rangeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null);
+ activeStartedAt = toIso(
+ authoritativeProgress?.firstTs ?? rangeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null
+ );
}
const moldActiveByIncident = new Map();
@@ -843,14 +1024,14 @@ async function computeRecap(params: Required> & {
production: {
goodParts,
scrapParts,
- totalCycles: dedupedCycles.length,
+ totalCycles: hasAuthoritativeWorkOrderCounters ? authoritativeCycleCount : dedupedCycles.length,
bySku,
},
oee: {
- avg: avg(oeeSum, oeeCount),
- availability: avg(availabilitySum, availabilityCount),
- performance: avg(performanceSum, performanceCount),
- quality: avg(qualitySum, qualityCount),
+ avg: weightedAvg("oee"),
+ availability: weightedAvg("availability"),
+ performance: weightedAvg("performance"),
+ quality: weightedAvg("quality"),
},
downtime: {
totalMin,
diff --git a/lib/recap/redesign.ts b/lib/recap/redesign.ts
new file mode 100644
index 0000000..f19367c
--- /dev/null
+++ b/lib/recap/redesign.ts
@@ -0,0 +1,679 @@
+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 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 = 10 * 60 * 1000;
+const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
+const RECAP_CACHE_TTL_SEC = 60;
+const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
+const WEEKDAY_KEY_MAP: Record = {
+ Mon: "mon",
+ Tue: "tue",
+ Wed: "wed",
+ Thu: "thu",
+ Fri: "fri",
+ Sat: "sat",
+ Sun: "sun",
+};
+
+function round2(value: number) {
+ return Math.round(value * 100) / 100;
+}
+
+function parseDate(input?: string | null) {
+ if (!input) return null;
+ const n = Number(input);
+ if (Number.isFinite(n)) {
+ const d = new Date(n);
+ return Number.isFinite(d.getTime()) ? d : null;
+ }
+ const d = new Date(input);
+ return Number.isFinite(d.getTime()) ? d : null;
+}
+
+function parseHours(input: string | null) {
+ const parsed = Math.trunc(Number(input ?? "24"));
+ if (!Number.isFinite(parsed)) return 24;
+ return Math.max(1, Math.min(72, parsed));
+}
+
+function parseTimeMinutes(input?: string | null) {
+ if (!input) return null;
+ const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
+ if (!match) return null;
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
+ return null;
+ }
+ return hours * 60 + minutes;
+}
+
+function getLocalParts(ts: Date, timeZone: string) {
+ try {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ weekday: "short",
+ hour12: false,
+ }).formatToParts(ts);
+
+ const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
+ const year = Number(value("year"));
+ const month = Number(value("month"));
+ const day = Number(value("day"));
+ const hour = Number(value("hour"));
+ const minute = Number(value("minute"));
+ const weekday = value("weekday");
+
+ return {
+ year,
+ month,
+ day,
+ hour,
+ minute,
+ weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
+ minutesOfDay: hour * 60 + minute,
+ };
+ } catch {
+ return {
+ year: ts.getUTCFullYear(),
+ month: ts.getUTCMonth() + 1,
+ day: ts.getUTCDate(),
+ hour: ts.getUTCHours(),
+ minute: ts.getUTCMinutes(),
+ weekday: WEEKDAY_KEYS[ts.getUTCDay()],
+ minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
+ };
+ }
+}
+
+function parseOffsetMinutes(offsetLabel: string | null) {
+ if (!offsetLabel) return null;
+ const normalized = offsetLabel.replace("UTC", "GMT");
+ const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
+ if (!match) return null;
+ const sign = match[1] === "-" ? -1 : 1;
+ const hour = Number(match[2]);
+ const minute = Number(match[3] ?? "0");
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
+ return sign * (hour * 60 + minute);
+}
+
+function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
+ try {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ timeZoneName: "shortOffset",
+ hour: "2-digit",
+ }).formatToParts(utcDate);
+ const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
+ return parseOffsetMinutes(offsetPart);
+ } catch {
+ return null;
+ }
+}
+
+function zonedToUtcDate(input: {
+ year: number;
+ month: number;
+ day: number;
+ hours: number;
+ minutes: number;
+ timeZone: string;
+}) {
+ const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
+ const guessDate = new Date(baseUtc);
+ const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
+ if (offsetA == null) return guessDate;
+
+ let corrected = new Date(baseUtc - offsetA * 60000);
+ const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
+ if (offsetB != null && offsetB !== offsetA) {
+ corrected = new Date(baseUtc - offsetB * 60000);
+ }
+
+ return corrected;
+}
+
+function addDays(input: { year: number; month: number; day: number }, days: number) {
+ const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
+ base.setUTCDate(base.getUTCDate() + days);
+ return {
+ year: base.getUTCFullYear(),
+ month: base.getUTCMonth() + 1,
+ day: base.getUTCDate(),
+ };
+}
+
+function statusFromMachine(machine: RecapMachine, endMs: number) {
+ const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
+ const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
+ const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
+
+ const ongoingStopMin = machine.downtime.ongoingStopMin ?? 0;
+ const moldActive = machine.workOrders.moldChangeInProgress;
+
+ let status: RecapMachineStatus = "running";
+ if (offline) status = "offline";
+ else if (moldActive) status = "mold-change";
+ else if (ongoingStopMin > 0) status = "stopped";
+
+ return {
+ status,
+ lastSeenMs,
+ offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
+ ongoingStopMin: machine.downtime.ongoingStopMin,
+ };
+}
+
+async function loadTimelineRowsForMachines(params: {
+ orgId: string;
+ machineIds: string[];
+ start: Date;
+ end: Date;
+}) {
+ if (!params.machineIds.length) {
+ return {
+ cyclesByMachine: new Map(),
+ eventsByMachine: new Map(),
+ };
+ }
+
+ const [cycles, events] = await Promise.all([
+ prisma.machineCycle.findMany({
+ where: {
+ orgId: params.orgId,
+ machineId: { in: params.machineIds },
+ ts: { gte: params.start, lte: params.end },
+ },
+ orderBy: [{ machineId: "asc" }, { ts: "asc" }],
+ select: {
+ machineId: true,
+ ts: true,
+ cycleCount: true,
+ actualCycleTime: true,
+ workOrderId: true,
+ sku: true,
+ },
+ }),
+ prisma.machineEvent.findMany({
+ where: {
+ orgId: params.orgId,
+ machineId: { in: params.machineIds },
+ eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
+ ts: {
+ gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
+ lte: params.end,
+ },
+ },
+ orderBy: [{ machineId: "asc" }, { ts: "asc" }],
+ select: {
+ machineId: true,
+ ts: true,
+ eventType: true,
+ data: true,
+ },
+ }),
+ ]);
+
+ const cyclesByMachine = new Map();
+ const eventsByMachine = new Map();
+
+ for (const row of cycles) {
+ const list = cyclesByMachine.get(row.machineId) ?? [];
+ list.push({
+ ts: row.ts,
+ cycleCount: row.cycleCount,
+ actualCycleTime: row.actualCycleTime,
+ workOrderId: row.workOrderId,
+ sku: row.sku,
+ });
+ cyclesByMachine.set(row.machineId, list);
+ }
+
+ for (const row of events) {
+ const list = eventsByMachine.get(row.machineId) ?? [];
+ list.push({
+ ts: row.ts,
+ eventType: row.eventType,
+ data: row.data,
+ });
+ eventsByMachine.set(row.machineId, list);
+ }
+
+ return { cyclesByMachine, eventsByMachine };
+}
+
+function toSummaryMachine(params: {
+ machine: RecapMachine;
+ miniTimeline: ReturnType;
+ rangeEndMs: number;
+}): RecapSummaryMachine {
+ const { machine, miniTimeline, rangeEndMs } = params;
+ const status = statusFromMachine(machine, rangeEndMs);
+
+ return {
+ machineId: machine.machineId,
+ name: machine.machineName,
+ location: machine.location,
+ status: status.status,
+ oee: machine.oee.avg,
+ goodParts: machine.production.goodParts,
+ scrap: machine.production.scrapParts,
+ stopsCount: machine.downtime.stopsCount,
+ lastSeenMs: status.lastSeenMs,
+ lastActivityMin:
+ status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
+ offlineForMin: status.offlineForMin,
+ ongoingStopMin: status.ongoingStopMin,
+ activeWorkOrderId: machine.workOrders.active?.id ?? null,
+ moldChange: {
+ active: machine.workOrders.moldChangeInProgress,
+ startMs: machine.workOrders.moldChangeStartMs,
+ elapsedMin:
+ machine.workOrders.moldChangeStartMs == null
+ ? null
+ : Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
+ },
+ miniTimeline,
+ };
+}
+
+async function computeRecapSummary(params: { orgId: string; hours: number }) {
+ const now = new Date();
+ const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
+ const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);
+
+ const recap = await getRecapDataCached({
+ orgId: params.orgId,
+ start,
+ end,
+ });
+
+ const machineIds = recap.machines.map((machine) => machine.machineId);
+ const timelineRows = await loadTimelineRowsForMachines({
+ orgId: params.orgId,
+ machineIds,
+ start,
+ end,
+ });
+
+ const machines = recap.machines.map((machine) => {
+ const segments = buildTimelineSegments({
+ cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
+ events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
+ rangeStart: start,
+ rangeEnd: end,
+ });
+ const miniTimeline = compressTimelineSegments({
+ segments,
+ rangeStart: start,
+ rangeEnd: end,
+ maxSegments: 30,
+ });
+
+ 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 null;
+
+ 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 end = zonedToUtcDate({
+ ...endDate,
+ hours: Math.floor(endMin / 60),
+ minutes: endMin % 60,
+ timeZone,
+ });
+
+ if (end <= start) continue;
+
+ return {
+ start,
+ end,
+ };
+ }
+
+ return null;
+}
+
+async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
+ const now = new Date();
+ const mode = normalizedRangeMode(params.input.mode);
+
+ if (mode === "custom") {
+ const start = parseDate(params.input.start);
+ const end = parseDate(params.input.end);
+ if (start && end && end > start) {
+ return { mode, start, end };
+ }
+ }
+
+ if (mode === "yesterday") {
+ const end = new Date(now.getTime() - 24 * 60 * 60 * 1000);
+ const start = new Date(end.getTime() - 24 * 60 * 60 * 1000);
+ return { mode, start, end };
+ }
+
+ if (mode === "shift") {
+ const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
+ if (shiftRange) {
+ return {
+ mode,
+ start: shiftRange.start,
+ end: shiftRange.end,
+ };
+ }
+ }
+
+ return {
+ mode: "24h" as const,
+ start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
+ end: now,
+ };
+}
+
+async function computeRecapMachineDetail(params: {
+ orgId: string;
+ machineId: string;
+ range: {
+ mode: RecapRangeMode;
+ start: Date;
+ end: Date;
+ };
+}) {
+ 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: {
+ mode: range.mode,
+ start: range.start.toISOString(),
+ end: range.end.toISOString(),
+ },
+ 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;
+ mode: RecapRangeMode;
+ startMs: number;
+ endMs: number;
+}) {
+ return [
+ "recap-detail-v1",
+ params.orgId,
+ params.machineId,
+ params.mode,
+ String(Math.trunc(params.startMs / 60000)),
+ String(Math.trunc(params.endMs / 60000)),
+ ];
+}
+
+export function parseRecapSummaryHours(raw: string | null) {
+ return parseHours(raw);
+}
+
+export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record) {
+ if (searchParams instanceof URLSearchParams) {
+ return {
+ mode: searchParams.get("range") ?? undefined,
+ start: searchParams.get("start") ?? undefined,
+ end: searchParams.get("end") ?? undefined,
+ };
+ }
+
+ const pick = (key: string) => {
+ const value = searchParams[key];
+ if (Array.isArray(value)) return value[0] ?? undefined;
+ return value ?? undefined;
+ };
+
+ return {
+ mode: pick("range"),
+ start: pick("start"),
+ end: pick("end"),
+ };
+}
+
+export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
+ const cache = unstable_cache(
+ () => computeRecapSummary(params),
+ summaryCacheKey(params),
+ {
+ revalidate: RECAP_CACHE_TTL_SEC,
+ tags: [`recap:${params.orgId}`],
+ }
+ );
+
+ return cache();
+}
+
+export async function getRecapMachineDetailCached(params: {
+ orgId: string;
+ machineId: string;
+ input: DetailRangeInput;
+}) {
+ const resolved = await resolveDetailRange({
+ orgId: params.orgId,
+ input: params.input,
+ });
+
+ const cache = unstable_cache(
+ () =>
+ computeRecapMachineDetail({
+ orgId: params.orgId,
+ machineId: params.machineId,
+ range: {
+ mode: resolved.mode,
+ start: resolved.start,
+ end: resolved.end,
+ },
+ }),
+ detailCacheKey({
+ orgId: params.orgId,
+ machineId: params.machineId,
+ mode: resolved.mode,
+ startMs: resolved.start.getTime(),
+ endMs: resolved.end.getTime(),
+ }),
+ {
+ revalidate: RECAP_CACHE_TTL_SEC,
+ tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
+ }
+ );
+
+ return cache();
+}
diff --git a/lib/recap/timeline.ts b/lib/recap/timeline.ts
new file mode 100644
index 0000000..353b586
--- /dev/null
+++ b/lib/recap/timeline.ts
@@ -0,0 +1,763 @@
+import type { RecapTimelineSegment } from "@/lib/recap/types";
+
+const ACTIVE_STALE_MS = 2 * 60 * 1000;
+const MERGE_GAP_MS = 30 * 1000;
+const MICRO_CLUSTER_GAP_MS = 60 * 1000;
+const ABSORB_SHORT_SEGMENT_MS = 30 * 1000;
+
+export const TIMELINE_EVENT_TYPES = ["mold-change", "macrostop", "microstop"] as const;
+
+type TimelineEventType = (typeof TIMELINE_EVENT_TYPES)[number];
+
+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;
+ };
+
+export type TimelineCycleRow = {
+ ts: Date;
+ cycleCount: number | null;
+ actualCycleTime: number;
+ workOrderId: string | null;
+ sku: string | null;
+};
+
+export type TimelineEventRow = {
+ ts: Date;
+ eventType: string;
+ data: unknown;
+};
+
+const PRIORITY: Record = {
+ idle: 0,
+ production: 1,
+ microstop: 2,
+ "slow-cycle": 2,
+ macrostop: 3,
+ "mold-change": 4,
+};
+
+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 safeBool(value: unknown) {
+ if (typeof value === "boolean") return value;
+ if (typeof value === "number") return value !== 0;
+ if (typeof value === "string") {
+ const normalized = value.trim().toLowerCase();
+ if (!normalized) return false;
+ return normalized === "true" || normalized === "1" || normalized === "yes";
+ }
+ return false;
+}
+
+function normalizeToken(value: unknown) {
+ return String(value ?? "").trim();
+}
+
+function dedupeByKey(rows: T[], keyFn: (row: T) => string) {
+ const seen = new Set();
+ 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 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)
+ : {};
+ const nested = record.data;
+ if (typeof nested === "object" && nested && !Array.isArray(nested)) {
+ return nested as Record;
+ }
+ 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, 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) {
+ 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;
+ 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 reason ? `Ciclo lento: ${reason}` : "Ciclo lento";
+}
+
+function normalizeStopType(type: "macrostop" | "microstop" | "slow-cycle"): "macrostop" | "microstop" {
+ return type === "macrostop" ? "macrostop" : "microstop";
+}
+
+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;
+}
+
+function withDuration(segment: RecapTimelineSegment): RecapTimelineSegment {
+ if (segment.type === "production") {
+ return {
+ ...segment,
+ durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
+ };
+ }
+ if (segment.type === "mold-change") {
+ return {
+ ...segment,
+ durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
+ };
+ }
+ if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
+ return {
+ ...segment,
+ durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
+ };
+ }
+ return {
+ ...segment,
+ durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
+ };
+}
+
+function cloneSegment(segment: RecapTimelineSegment): RecapTimelineSegment {
+ return { ...segment };
+}
+
+function mergeNearbyEquivalentSegments(segments: RecapTimelineSegment[], maxGapMs: number) {
+ const ordered = [...segments]
+ .map((segment) => withDuration(segment))
+ .filter((segment) => segment.endMs > segment.startMs)
+ .sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
+
+ const merged: RecapTimelineSegment[] = [];
+ for (const current of ordered) {
+ const prev = merged[merged.length - 1];
+ if (!prev) {
+ merged.push(cloneSegment(current));
+ continue;
+ }
+
+ const gapMs = current.startMs - prev.endMs;
+ if (gapMs <= maxGapMs && isEquivalent(prev, current)) {
+ prev.endMs = Math.max(prev.endMs, current.endMs);
+ const normalized = withDuration(prev);
+ Object.assign(prev, normalized);
+ continue;
+ }
+
+ if (current.startMs < prev.endMs) {
+ const clipped = { ...current, startMs: prev.endMs };
+ if (clipped.endMs <= clipped.startMs) continue;
+ merged.push(withDuration(clipped));
+ continue;
+ }
+
+ merged.push(cloneSegment(current));
+ }
+
+ return merged;
+}
+
+function fillGapsWithIdle(segments: RecapTimelineSegment[], rangeStartMs: number, rangeEndMs: number) {
+ const ordered = [...segments]
+ .map((segment) => {
+ const startMs = Math.max(rangeStartMs, segment.startMs);
+ const endMs = Math.min(rangeEndMs, segment.endMs);
+ if (endMs <= startMs) return null;
+ return withDuration({ ...segment, startMs, endMs });
+ })
+ .filter((segment): segment is RecapTimelineSegment => !!segment)
+ .sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
+
+ const out: RecapTimelineSegment[] = [];
+ let cursor = rangeStartMs;
+
+ for (const segment of ordered) {
+ if (segment.startMs > cursor) {
+ out.push({
+ type: "idle",
+ startMs: cursor,
+ endMs: segment.startMs,
+ durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
+ label: "Idle",
+ });
+ }
+
+ const startMs = Math.max(cursor, segment.startMs);
+ const endMs = Math.min(rangeEndMs, segment.endMs);
+ if (endMs <= startMs) continue;
+
+ out.push(withDuration({ ...segment, startMs, endMs }));
+ cursor = endMs;
+ if (cursor >= rangeEndMs) break;
+ }
+
+ if (cursor < rangeEndMs) {
+ out.push({
+ type: "idle",
+ startMs: cursor,
+ endMs: rangeEndMs,
+ durationSec: Math.max(0, Math.trunc((rangeEndMs - cursor) / 1000)),
+ label: "Idle",
+ });
+ }
+
+ return mergeNearbyEquivalentSegments(out, 0);
+}
+
+function absorbMicroStopClusters(segments: RecapTimelineSegment[], maxGapMs: number) {
+ const out: RecapTimelineSegment[] = [];
+ let i = 0;
+
+ while (i < segments.length) {
+ const first = segments[i];
+ if (first.type !== "microstop") {
+ out.push(cloneSegment(first));
+ i += 1;
+ continue;
+ }
+
+ let clusterEndMs = first.endMs;
+ let count = 1;
+ const reasons = new Set();
+ if (first.reason) reasons.add(first.reason);
+
+ let cursor = i;
+ while (cursor + 2 < segments.length) {
+ const gap = segments[cursor + 1];
+ const next = segments[cursor + 2];
+ if (next.type !== "microstop") break;
+ if (gap.type === "macrostop" || gap.type === "mold-change") break;
+ const gapMs = Math.max(0, gap.endMs - gap.startMs);
+ if (gapMs >= maxGapMs) break;
+
+ clusterEndMs = next.endMs;
+ if (next.reason) reasons.add(next.reason);
+ count += 1;
+ cursor += 2;
+ }
+
+ if (count === 1) {
+ out.push(cloneSegment(first));
+ i += 1;
+ continue;
+ }
+
+ const reason = reasons.size === 1 ? (Array.from(reasons)[0] ?? null) : null;
+ out.push({
+ type: "microstop",
+ startMs: first.startMs,
+ endMs: clusterEndMs,
+ reason,
+ reasonLabel: reason,
+ durationSec: Math.max(0, Math.trunc((clusterEndMs - first.startMs) / 1000)),
+ label: reason ? `Microparo (${count}) · ${reason}` : `Microparo (${count})`,
+ });
+ i = cursor + 1;
+ }
+
+ return mergeNearbyEquivalentSegments(out, 0);
+}
+
+function absorbShortSegments(segments: RecapTimelineSegment[], minDurationMs: number) {
+ const out = segments.map((segment) => withDuration(cloneSegment(segment)));
+ let index = 0;
+
+ while (index < out.length) {
+ const current = out[index];
+ const durationMs = Math.max(0, current.endMs - current.startMs);
+ if (durationMs >= minDurationMs || out.length === 1) {
+ index += 1;
+ continue;
+ }
+
+ const prev = out[index - 1] ?? null;
+ const next = out[index + 1] ?? null;
+ if (!prev && !next) break;
+
+ if (!prev && next) {
+ next.startMs = current.startMs;
+ out.splice(index, 1);
+ continue;
+ }
+
+ if (prev && !next) {
+ prev.endMs = current.endMs;
+ out.splice(index, 1);
+ index = Math.max(0, index - 1);
+ continue;
+ }
+
+ const prevDurationMs = Math.max(0, (prev?.endMs ?? 0) - (prev?.startMs ?? 0));
+ const nextDurationMs = Math.max(0, (next?.endMs ?? 0) - (next?.startMs ?? 0));
+ const absorbIntoPrev = prevDurationMs >= nextDurationMs;
+
+ if (absorbIntoPrev && prev) {
+ prev.endMs = current.endMs;
+ out.splice(index, 1);
+ index = Math.max(0, index - 1);
+ continue;
+ }
+
+ if (next) {
+ next.startMs = current.startMs;
+ out.splice(index, 1);
+ continue;
+ }
+
+ index += 1;
+ }
+
+ return mergeNearbyEquivalentSegments(out.map((segment) => withDuration(segment)), MERGE_GAP_MS);
+}
+
+function buildSegmentsFromBoundaries(rawSegments: RawSegment[], rangeStartMs: number, rangeEndMs: number) {
+ 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([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) continue;
+
+ if (winner.type === "production") {
+ timeline.push({
+ type: "production",
+ startMs: intervalStart,
+ endMs: intervalEnd,
+ durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
+ 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;
+ }
+
+ const stopType = normalizeStopType(winner.type);
+ timeline.push({
+ type: stopType,
+ startMs: intervalStart,
+ endMs: intervalEnd,
+ reason: winner.reason,
+ reasonLabel: winner.reason,
+ durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
+ label: labelForStop(stopType, winner.reason),
+ });
+ }
+
+ return timeline;
+}
+
+function segmentPriority(type: RecapTimelineSegment["type"]) {
+ if (type === "mold-change") return 4;
+ if (type === "macrostop") return 3;
+ if (type === "microstop" || type === "slow-cycle") return 2;
+ if (type === "production") return 1;
+ return 0;
+}
+
+function cloneForRange(segment: RecapTimelineSegment, startMs: number, endMs: number): RecapTimelineSegment {
+ if (segment.type === "production") {
+ return {
+ type: "production",
+ startMs,
+ endMs,
+ durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
+ workOrderId: segment.workOrderId,
+ sku: segment.sku,
+ label: segment.label,
+ };
+ }
+ if (segment.type === "mold-change") {
+ return {
+ type: "mold-change",
+ startMs,
+ endMs,
+ fromMoldId: segment.fromMoldId,
+ toMoldId: segment.toMoldId,
+ durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
+ label: segment.label,
+ };
+ }
+ if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
+ const stopType = normalizeStopType(segment.type);
+ return {
+ type: stopType,
+ startMs,
+ endMs,
+ reason: segment.reason,
+ reasonLabel: segment.reasonLabel ?? segment.reason,
+ durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
+ label: segment.label,
+ };
+ }
+ return {
+ type: "idle",
+ startMs,
+ endMs,
+ durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
+ label: segment.label,
+ };
+}
+
+export function buildTimelineSegments(input: {
+ cycles: TimelineCycleRow[];
+ events: TimelineEventRow[];
+ rangeStart: Date;
+ rangeEnd: Date;
+}) {
+ const rangeStartMs = input.rangeStart.getTime();
+ const rangeEndMs = input.rangeEnd.getTime();
+
+ if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs <= rangeStartMs) {
+ return [] as RecapTimelineSegment[];
+ }
+
+ const dedupedCycles = dedupeByKey(
+ input.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";
+ 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 input.events) {
+ const eventType = String(event.eventType || "").toLowerCase() as TimelineEventType;
+ 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;
+
+ 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;
+ }
+
+ rawSegments.push({
+ type: episode.type,
+ startMs,
+ endMs,
+ priority: PRIORITY[episode.type],
+ reason: episode.reason,
+ durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
+ label: labelForStop(episode.type, episode.reason),
+ });
+ }
+
+ const initial = buildSegmentsFromBoundaries(rawSegments, rangeStartMs, rangeEndMs);
+ const merged = mergeNearbyEquivalentSegments(initial, MERGE_GAP_MS);
+ const withIdle = fillGapsWithIdle(merged, rangeStartMs, rangeEndMs);
+ const clustered = absorbMicroStopClusters(withIdle, MICRO_CLUSTER_GAP_MS);
+ const normalized = fillGapsWithIdle(clustered, rangeStartMs, rangeEndMs);
+ const absorbed = absorbShortSegments(normalized, ABSORB_SHORT_SEGMENT_MS);
+ const finalSegments = fillGapsWithIdle(absorbed, rangeStartMs, rangeEndMs);
+
+ return finalSegments;
+}
+
+export function compressTimelineSegments(input: {
+ segments: RecapTimelineSegment[];
+ rangeStart: Date;
+ rangeEnd: Date;
+ maxSegments: number;
+}) {
+ const rangeStartMs = input.rangeStart.getTime();
+ const rangeEndMs = input.rangeEnd.getTime();
+ const maxSegments = Math.max(1, Math.trunc(input.maxSegments || 1));
+
+ const normalized = fillGapsWithIdle(input.segments, rangeStartMs, rangeEndMs);
+ if (normalized.length <= maxSegments) return normalized;
+
+ const totalMs = Math.max(1, rangeEndMs - rangeStartMs);
+ const bucketMs = totalMs / maxSegments;
+ const buckets: RecapTimelineSegment[] = [];
+
+ for (let i = 0; i < maxSegments; i += 1) {
+ const bucketStart = Math.trunc(rangeStartMs + i * bucketMs);
+ const bucketEnd = i === maxSegments - 1 ? rangeEndMs : Math.trunc(rangeStartMs + (i + 1) * bucketMs);
+ if (bucketEnd <= bucketStart) continue;
+
+ let winner: RecapTimelineSegment | null = null;
+ let winnerOverlap = -1;
+
+ for (const segment of normalized) {
+ const overlapStart = Math.max(bucketStart, segment.startMs);
+ const overlapEnd = Math.min(bucketEnd, segment.endMs);
+ if (overlapEnd <= overlapStart) continue;
+
+ const overlap = overlapEnd - overlapStart;
+ const priorityBonus = segmentPriority(segment.type) / 1000;
+ const score = overlap + priorityBonus;
+ if (score > winnerOverlap) {
+ winner = segment;
+ winnerOverlap = score;
+ }
+ }
+
+ if (!winner) {
+ buckets.push({
+ type: "idle",
+ startMs: bucketStart,
+ endMs: bucketEnd,
+ durationSec: Math.max(0, Math.trunc((bucketEnd - bucketStart) / 1000)),
+ label: "Idle",
+ });
+ continue;
+ }
+
+ buckets.push(cloneForRange(winner, bucketStart, bucketEnd));
+ }
+
+ const merged = mergeNearbyEquivalentSegments(buckets, 0);
+ return fillGapsWithIdle(merged, rangeStartMs, rangeEndMs);
+}
diff --git a/lib/recap/timelineApi.ts b/lib/recap/timelineApi.ts
new file mode 100644
index 0000000..e58dbc3
--- /dev/null
+++ b/lib/recap/timelineApi.ts
@@ -0,0 +1,185 @@
+import { prisma } from "@/lib/prisma";
+import {
+ buildTimelineSegments,
+ compressTimelineSegments,
+ TIMELINE_EVENT_TYPES,
+ type TimelineCycleRow,
+ type TimelineEventRow,
+} from "@/lib/recap/timeline";
+import type { RecapTimelineResponse } from "@/lib/recap/types";
+
+const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
+const DEFAULT_RANGE_MS = 24 * 60 * 60 * 1000;
+const MIN_RANGE_MS = 60 * 1000;
+const MAX_RANGE_MS = 72 * 60 * 60 * 1000;
+
+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 parseRangeDurationMs(raw: string | null) {
+ if (!raw) return null;
+ const normalized = raw.trim().toLowerCase();
+ const match = /^(\d+)\s*([hm])$/.exec(normalized);
+ if (!match) return null;
+
+ const amount = Number(match[1]);
+ if (!Number.isFinite(amount) || amount <= 0) return null;
+ const unit = match[2];
+ const durationMs = unit === "m" ? amount * 60_000 : amount * 60 * 60_000;
+ return Math.max(MIN_RANGE_MS, Math.min(MAX_RANGE_MS, durationMs));
+}
+
+function parseHours(raw: string | null) {
+ if (!raw) return null;
+ const parsed = Math.trunc(Number(raw));
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
+ return Math.max(1, Math.min(72, parsed));
+}
+
+function parseMaxSegments(searchParams: URLSearchParams) {
+ const compact = searchParams.get("compact");
+ const maxSegmentsRaw = searchParams.get("maxSegments");
+ if (compact !== "1" && compact !== "true" && !maxSegmentsRaw) return null;
+
+ const parsed = Math.trunc(Number(maxSegmentsRaw ?? "30"));
+ if (!Number.isFinite(parsed) || parsed <= 0) return 30;
+ return Math.max(5, Math.min(120, parsed));
+}
+
+export function parseRecapTimelineRange(searchParams: URLSearchParams) {
+ const end = parseDateInput(searchParams.get("end")) ?? new Date();
+ const startParam = parseDateInput(searchParams.get("start"));
+ if (startParam && startParam < end) {
+ return {
+ start: startParam,
+ end,
+ maxSegments: parseMaxSegments(searchParams),
+ };
+ }
+
+ const rangeDurationMs =
+ parseRangeDurationMs(searchParams.get("range")) ??
+ (() => {
+ const hours = parseHours(searchParams.get("hours"));
+ return hours ? hours * 60 * 60 * 1000 : null;
+ })() ??
+ DEFAULT_RANGE_MS;
+
+ const start = new Date(end.getTime() - Math.max(MIN_RANGE_MS, Math.min(MAX_RANGE_MS, rangeDurationMs)));
+ return {
+ start,
+ end,
+ maxSegments: parseMaxSegments(searchParams),
+ };
+}
+
+export async function getRecapTimelineForMachine(params: {
+ orgId: string;
+ machineId: string;
+ start: Date;
+ end: Date;
+ maxSegments?: number | null;
+}) {
+ const [cyclesRaw, eventsRaw, cycleCount, eventCount] = await Promise.all([
+ prisma.machineCycle.findMany({
+ where: {
+ orgId: params.orgId,
+ machineId: params.machineId,
+ ts: { gte: params.start, lte: params.end },
+ },
+ orderBy: { ts: "asc" },
+ select: {
+ ts: true,
+ cycleCount: true,
+ actualCycleTime: true,
+ workOrderId: true,
+ sku: true,
+ },
+ }),
+ prisma.machineEvent.findMany({
+ where: {
+ orgId: params.orgId,
+ machineId: params.machineId,
+ eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
+ ts: {
+ gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
+ lte: params.end,
+ },
+ },
+ orderBy: { ts: "asc" },
+ select: {
+ ts: true,
+ eventType: true,
+ data: true,
+ },
+ }),
+ prisma.machineCycle.count({
+ where: {
+ orgId: params.orgId,
+ machineId: params.machineId,
+ ts: { gte: params.start, lte: params.end },
+ },
+ }),
+ prisma.machineEvent.count({
+ where: {
+ orgId: params.orgId,
+ machineId: params.machineId,
+ ts: { gte: params.start, lte: params.end },
+ },
+ }),
+ ]);
+
+ const hasData = cycleCount > 0 || eventCount > 0;
+
+ const cycles: TimelineCycleRow[] = cyclesRaw.map((row) => ({
+ ts: row.ts,
+ cycleCount: row.cycleCount,
+ actualCycleTime: row.actualCycleTime,
+ workOrderId: row.workOrderId,
+ sku: row.sku,
+ }));
+
+ const events: TimelineEventRow[] = eventsRaw.map((row) => ({
+ ts: row.ts,
+ eventType: row.eventType,
+ data: row.data,
+ }));
+
+ let segments = hasData
+ ? buildTimelineSegments({
+ cycles,
+ events,
+ rangeStart: params.start,
+ rangeEnd: params.end,
+ })
+ : [];
+
+ if (hasData && params.maxSegments && params.maxSegments > 0) {
+ segments = compressTimelineSegments({
+ segments,
+ rangeStart: params.start,
+ rangeEnd: params.end,
+ maxSegments: params.maxSegments,
+ });
+ }
+
+ const response: RecapTimelineResponse = {
+ range: {
+ start: params.start.toISOString(),
+ end: params.end.toISOString(),
+ },
+ segments,
+ hasData,
+ generatedAt: new Date().toISOString(),
+ };
+
+ return response;
+}
diff --git a/lib/recap/types.ts b/lib/recap/types.ts
index e925ac2..4735fba 100644
--- a/lib/recap/types.ts
+++ b/lib/recap/types.ts
@@ -60,6 +60,7 @@ export type RecapTimelineSegment =
type: "production";
startMs: number;
endMs: number;
+ durationSec: number;
workOrderId: string | null;
sku: string | null;
label: string;
@@ -78,6 +79,7 @@ export type RecapTimelineSegment =
startMs: number;
endMs: number;
reason: string | null;
+ reasonLabel?: string | null;
durationSec: number;
label: string;
}
@@ -85,6 +87,7 @@ export type RecapTimelineSegment =
type: "idle";
startMs: number;
endMs: number;
+ durationSec: number;
label: string;
};
@@ -94,6 +97,8 @@ export type RecapTimelineResponse = {
end: string;
};
segments: RecapTimelineSegment[];
+ hasData: boolean;
+ generatedAt: string;
};
export type RecapResponse = {
@@ -115,3 +120,100 @@ export type RecapQuery = {
end?: Date;
shift?: string;
};
+
+export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline";
+
+export type RecapSummaryMachine = {
+ machineId: string;
+ name: string;
+ location: string | null;
+ status: RecapMachineStatus;
+ oee: number | null;
+ goodParts: number;
+ scrap: number;
+ stopsCount: number;
+ lastSeenMs: number | null;
+ lastActivityMin: number | null;
+ offlineForMin: number | null;
+ ongoingStopMin: number | null;
+ activeWorkOrderId: string | null;
+ moldChange: {
+ active: boolean;
+ startMs: number | null;
+ elapsedMin: number | null;
+ } | null;
+ miniTimeline: RecapTimelineSegment[];
+};
+
+export type RecapSummaryResponse = {
+ generatedAt: string;
+ range: {
+ start: string;
+ end: string;
+ hours: number;
+ };
+ machines: RecapSummaryMachine[];
+};
+
+export type RecapRangeMode = "24h" | "shift" | "yesterday" | "custom";
+
+export type RecapDowntimeTopRow = {
+ reasonLabel: string;
+ minutes: number;
+ count: number;
+ percent: number;
+};
+
+export type RecapWorkOrders = {
+ completed: Array<{
+ id: string;
+ sku: string | null;
+ goodParts: number;
+ durationHrs: number;
+ }>;
+ active: {
+ id: string;
+ sku: string | null;
+ progressPct: number | null;
+ startedAt: string | null;
+ } | null;
+};
+
+export type RecapMachineDetail = {
+ machineId: string;
+ name: string;
+ location: string | null;
+ status: RecapMachineStatus;
+ oee: number | null;
+ goodParts: number;
+ scrap: number;
+ stopsCount: number;
+ stopMinutes: number;
+ activeWorkOrderId: string | null;
+ lastSeenMs: number | null;
+ offlineForMin: number | null;
+ ongoingStopMin: number | null;
+ moldChange: {
+ active: boolean;
+ startMs: number | null;
+ } | null;
+ timeline: RecapTimelineSegment[];
+ productionBySku: RecapSkuRow[];
+ downtimeTop: RecapDowntimeTopRow[];
+ workOrders: RecapWorkOrders;
+ heartbeat: {
+ lastSeenAt: string | null;
+ uptimePct: number | null;
+ connectionStatus: "online" | "offline";
+ };
+};
+
+export type RecapDetailResponse = {
+ generatedAt: string;
+ range: {
+ mode: RecapRangeMode;
+ start: string;
+ end: string;
+ };
+ machine: RecapMachineDetail;
+};
diff --git a/recap_fix.md b/recap_fix.md
new file mode 100644
index 0000000..795b926
--- /dev/null
+++ b/recap_fix.md
@@ -0,0 +1,144 @@
+Recap Redesign — Handoff Prompt
+Goal
+Replace the current aggregated /recap view with a two-level drill-down: machine grid → machine-specific 24h detail. One machine = one clear story. No mixed averages.
+
+Architecture
+/recap → grid of machine cards (overview)
+/recap/[machineId] → full recap detail for one machine
+Level 1: /recap (grid)
+Layout
+Reuse the pattern from app/(app)/machines/MachinesClient.tsx. Same card grid, same responsive breakpoints, same filters (location, status).
+
+Card contents (per machine)
+Header: machine name + location + status dot (green=running, amber=mold-change, red=stopped, gray=offline)
+Big number: today's OEE % (or good parts — pick one primary metric and stick with it)
+Secondary row: good parts · scrap · stops count
+Mini timeline bar: compressed 24h bar (height 20px), same color coding as detail page. No labels, tooltip only. Clicking anywhere navigates to detail.
+Footer: "Última actividad hace X min" or current WO id if active
+Banners (inline, colored):
+If mold-change active → amber: "Cambio de molde en curso · Xm"
+If machine offline >10 min → red: "Sin señal hace Xm"
+Data source
+New endpoint app/api/recap/summary/route.ts — returns array of per-machine summaries in one query. Cache 60s.
+
+GET /api/recap/summary?hours=24
+→ {
+ machines: [{
+ machineId, name, location, status,
+ oee, goodParts, scrap, stopsCount,
+ lastSeenMs, activeWorkOrderId,
+ moldChange: { active, startMs } | null,
+ miniTimeline: Segment[] // compressed, max ~30 segments
+ }]
+ }
+Empty / loading states
+Skeleton cards while loading (pulse animation, same size as real card).
+Zero-activity machine: card renders but with "Sin producción" muted text, gray mini bar, metric "—".
+Level 2: /recap/[machineId]
+Layout (top to bottom)
+Back arrow + machine name breadcrumb — ← Todas las máquinas / M4-5
+Range picker — 24h / Turno actual / Ayer / Personalizado (top-right)
+Banners — mold-change / offline / ongoing-stop (full-width, colored)
+KPI row (4 cards) — OEE, Buenas, Paros totales (min), Scrap
+Timeline 24h — full-width smooth bar (see fix from previous message: min 1.5% width, no dots, merged consecutive stops)
+Two-column row:
+Left: Producción por SKU (table) — SKU | Buenas | Scrap | Meta | Avance%
+Right: Top downtime (pareto) — top 3 reasons with minutes + percent
+Work orders — two side-by-side lists:
+Completadas: id, SKU, parts, duration
+Activa: id, SKU, progress bar, started-at
+Estado máquina — last heartbeat, uptime %, connection status
+Data source
+Endpoint app/api/recap/[machineId]/route.ts — the detailed payload (shape I already documented in the earlier handoff). Cache 60s keyed by {machineId, range}.
+
+Navigation
+Sidebar "Resumen" stays → routes to /recap grid.
+MachineCard onClick → router.push('/recap/' + machineId).
+Breadcrumb on detail page navigates back to grid.
+Deep link safe: /recap/ works standalone.
+Shared components
+Build these in components/recap/:
+
+RecapMachineCard.tsx — the grid card. Props: machine summary object.
+RecapMiniTimeline.tsx — 20px-high compressed bar, no labels, tooltip only.
+RecapFullTimeline.tsx — 48-56px bar, labels on wide segments (>5% width), minimum segment width 1.5%, rounded only on first/last child.
+RecapKpiRow.tsx — reused from prior design.
+RecapProductionBySku.tsx, RecapDowntimeTop.tsx, RecapWorkOrders.tsx, RecapMachineStatus.tsx — detail-page sections.
+RecapBanners.tsx — mold-change / offline / ongoing-stop alert bars.
+Timeline specifics (fix the ugly-dots issue)
+Both mini and full versions share segment-builder logic. Server-side:
+
+Walk 24h chronologically, produce raw segments from MachineCycle, MachineEvent, MachineWorkOrder.
+Gap-fill — any time between segments with no data → idle segment.
+Merge pass — consecutive same-type segments separated by <30s → merge.
+Absorb micro-runs — runs of microstops closer than 60s → single microstop-cluster segment with aggregated duration and count.
+Minimum display width — server returns raw segments; client enforces Math.max(1.5, pct) so nothing renders as a dot.
+Client:
+
+display: flex; overflow: hidden; border-radius: 0.75rem on container.
+Each child: width % only, no margin, no gap, no border-right.
+Only labels if segment width >5% (else title tooltip).
+Color map exactly:
+production: bg-emerald-500 text-black
+mold-change: bg-sky-400 text-black
+macrostop: bg-red-500 text-white
+microstop: bg-orange-500 text-black
+idle: bg-zinc-700 text-zinc-300
+i18n
+Every user-visible string routes through useI18n(). Add to Spanish locale (primary):
+
+recap.grid.title = "Resumen de máquinas"
+recap.grid.subtitle = "Últimas 24h · click para ver detalle"
+recap.detail.back = "Todas las máquinas"
+recap.card.oee = "OEE"
+recap.card.good = "Piezas buenas"
+recap.card.stops = "Paros"
+recap.banner.moldChange = "Cambio de molde en curso desde {time}"
+recap.banner.offline = "Sin señal hace {min} min"
+recap.banner.ongoingStop = "Máquina detenida hace {min} min"
+recap.production.bySku = "Producción por SKU"
+recap.downtime.top = "Top paros"
+...
+English keys mirror.
+
+Accessibility / responsive
+Cards collapse to single column <640px.
+Timeline stays readable — horizontal scroll if really tight.
+Keyboard navigable: cards are