From 6aaafb9115db14ec6e1796fc41233f1a8d33bf3b Mon Sep 17 00:00:00 2001 From: Marcelo Date: Fri, 24 Apr 2026 14:06:15 +0000 Subject: [PATCH] reliability semi-fix --- Reliability.md | 161 ++++ .../[machineId]/MachineDetailClient.tsx | 196 ++--- app/(app)/recap/RecapClient.tsx | 310 ------- app/(app)/recap/RecapGridClient.tsx | 153 ++++ .../recap/[machineId]/RecapDetailClient.tsx | 213 +++++ app/(app)/recap/[machineId]/loading.tsx | 13 + app/(app)/recap/[machineId]/page.tsx | 35 + app/(app)/recap/loading.tsx | 12 + app/(app)/recap/page.tsx | 42 +- app/api/recap/[machineId]/route.ts | 37 + app/api/recap/[machineId]/timeline/route.ts | 42 + app/api/recap/summary/route.ts | 21 + app/api/recap/timeline/route.ts | 448 +--------- components/recap/RecapBanners.tsx | 44 + components/recap/RecapDowntimeTop.tsx | 50 +- components/recap/RecapFullTimeline.tsx | 83 ++ components/recap/RecapKpiRow.tsx | 21 +- components/recap/RecapMachineCard.tsx | 154 ++++ components/recap/RecapMachineStatus.tsx | 32 +- components/recap/RecapMiniTimeline.tsx | 82 ++ components/recap/RecapProductionBySku.tsx | 53 +- components/recap/RecapTimeline.tsx | 181 +++-- components/recap/RecapWorkOrders.tsx | 59 ++ components/recap/timelineRender.ts | 150 ++++ lib/i18n/en.json | 49 +- lib/i18n/es-MX.json | 53 +- lib/recap/getRecapData.ts | 275 +++++-- lib/recap/redesign.ts | 679 ++++++++++++++++ lib/recap/timeline.ts | 763 ++++++++++++++++++ lib/recap/timelineApi.ts | 185 +++++ lib/recap/types.ts | 102 +++ recap_fix.md | 144 ++++ 32 files changed, 3749 insertions(+), 1093 deletions(-) create mode 100644 Reliability.md delete mode 100644 app/(app)/recap/RecapClient.tsx create mode 100644 app/(app)/recap/RecapGridClient.tsx create mode 100644 app/(app)/recap/[machineId]/RecapDetailClient.tsx create mode 100644 app/(app)/recap/[machineId]/loading.tsx create mode 100644 app/(app)/recap/[machineId]/page.tsx create mode 100644 app/(app)/recap/loading.tsx create mode 100644 app/api/recap/[machineId]/route.ts create mode 100644 app/api/recap/[machineId]/timeline/route.ts create mode 100644 app/api/recap/summary/route.ts create mode 100644 components/recap/RecapBanners.tsx create mode 100644 components/recap/RecapFullTimeline.tsx create mode 100644 components/recap/RecapMachineCard.tsx create mode 100644 components/recap/RecapMiniTimeline.tsx create mode 100644 components/recap/RecapWorkOrders.tsx create mode 100644 components/recap/timelineRender.ts create mode 100644 lib/recap/redesign.ts create mode 100644 lib/recap/timeline.ts create mode 100644 lib/recap/timelineApi.ts create mode 100644 recap_fix.md 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} -
-
- ); - })} +
+ + + + + + + + + + + + {rows.slice(0, 10).map((row) => { + const progress = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`; + return ( + + + + + + + + ); + })} + +
{t("recap.production.sku")}{t("recap.production.good")}{t("recap.production.scrap")}{t("recap.production.target")}{t("recap.production.progress")}
{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