almost_done

This commit is contained in:
Marcelo
2026-04-30 16:59:42 +00:00
parent 5e7ddaa0db
commit b2214ec46f
23 changed files with 662 additions and 196 deletions

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState, type KeyboardEvent } from "react"; import { useEffect, useState, type KeyboardEvent } from "react";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
type MachineRow = { type MachineRow = {
id: string; id: string;
@@ -20,6 +21,7 @@ type MachineRow = {
}; };
}; };
const LIVE_REFRESH_MS = 5000; const LIVE_REFRESH_MS = 5000;
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
function secondsAgo(ts: string | undefined, locale: string, fallback: string) { function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return fallback; if (!ts) return fallback;
@@ -31,7 +33,7 @@ function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
function isOffline(ts?: string) { function isOffline(ts?: string) {
if (!ts) return true; if (!ts) return true;
return Date.now() - new Date(ts).getTime() > 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS) return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
} }
function normalizeStatus(status?: string) { function normalizeStatus(status?: string) {

View File

@@ -21,6 +21,7 @@ import {
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode"; import { useScreenlessMode } from "@/lib/ui/screenlessMode";
import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types"; import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import { import {
computeWidths, computeWidths,
formatDuration, formatDuration,
@@ -375,6 +376,7 @@ function getMinuteFlooredOneHourRange(referenceMs = Date.now()) {
function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) { function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null); const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [timelineLoading, setTimelineLoading] = useState(true); const [timelineLoading, setTimelineLoading] = useState(true);
const [showWindowInfo, setShowWindowInfo] = useState(false);
const timelineHashRef = useRef(""); const timelineHashRef = useRef("");
useEffect(() => { useEffect(() => {
@@ -444,7 +446,13 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
<div className="text-sm font-semibold text-white">{t("machine.detail.activity.title")}</div> <div className="text-sm font-semibold text-white">{t("machine.detail.activity.title")}</div>
<div className="mt-1 text-xs text-zinc-400">{t("machine.detail.activity.subtitle")}</div> <div className="mt-1 text-xs text-zinc-400">{t("machine.detail.activity.subtitle")}</div>
</div> </div>
<div className="text-xs text-zinc-400">1h</div> <button
type="button"
onClick={() => setShowWindowInfo(true)}
className="rounded-md border border-white/20 px-2 py-1 text-xs text-zinc-300 hover:border-white/40 hover:text-white"
>
{t("machine.detail.activity.windowBadge")}
</button>
</div> </div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300"> <div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
@@ -500,6 +508,31 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
)} )}
</div> </div>
</div> </div>
{showWindowInfo ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="machine-timeline-window-title"
>
<div className="w-full max-w-sm rounded-2xl border border-white/15 bg-zinc-950 p-5">
<h3 id="machine-timeline-window-title" className="text-sm font-semibold text-white">
{t("machine.detail.activity.windowModalTitle")}
</h3>
<p className="mt-2 text-sm text-zinc-300">{t("machine.detail.activity.windowModalBody")}</p>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => setShowWindowInfo(false)}
className="rounded-lg border border-white/20 px-3 py-1.5 text-sm text-zinc-200 hover:border-white/40 hover:text-white"
>
{t("common.close")}
</button>
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }
@@ -800,7 +833,7 @@ export default function MachineDetailClient() {
function isOffline(ts?: string) { function isOffline(ts?: string) {
if (!ts) return true; if (!ts) return true;
return Date.now() - new Date(ts).getTime() > 30000; return Date.now() - new Date(ts).getTime() > RECAP_HEARTBEAT_STALE_MS;
} }
function normalizeStatus(status?: string) { function normalizeStatus(status?: string) {

View File

@@ -3,9 +3,10 @@
import Link from "next/link"; import Link from "next/link";
import { Suspense, lazy, useEffect, useMemo, useState } from "react"; import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import type { EventRow, Heartbeat, MachineRow } from "./types"; import type { EventRow, Heartbeat, MachineRow } from "./types";
const OFFLINE_MS = 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS) const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
const MAX_EVENT_MACHINES = 6; const MAX_EVENT_MACHINES = 6;
const OverviewTimeline = lazy(() => import("./OverviewTimeline")); const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
@@ -199,20 +200,65 @@ export default function OverviewClient({
.map((m) => { .map((m) => {
const hb = m.latestHeartbeat; const hb = m.latestHeartbeat;
const offline = isOffline(heartbeatTime(hb)); const offline = isOffline(heartbeatTime(hb));
const status = normalizeStatus(hb?.status);
const k = m.latestKpi; const k = m.latestKpi;
const oee = k?.oee ?? null; const oee = k?.oee ?? null;
const good = k?.good ?? null;
const scrap = k?.scrap ?? null;
const availability = k?.availability ?? null;
const reasons: string[] = [];
let score = 0; let score = 0;
if (offline) score += 100;
if (oee != null && oee < 75) score += 50; // Trigger 1: offline (highest priority — can't tell what's wrong)
if (oee != null && oee < 85) score += 25; if (offline) {
return { machine: m, offline, oee, score }; score += 100;
reasons.push(t("overview.attention.offline"));
}
// Trigger 2: stopped right now (and online — operator should act)
if (!offline && (status === "STOP" || status === "DOWN")) {
score += 60;
reasons.push(t("overview.attention.stopped"));
}
// Trigger 3: low OEE
if (!offline && oee != null) {
if (oee < 50) {
score += 50;
reasons.push(t("overview.attention.oeeCritical", { value: oee.toFixed(0) }));
} else if (oee < 75) {
score += 30;
reasons.push(t("overview.attention.oeeLow", { value: oee.toFixed(0) }));
}
}
// Trigger 4: scrap rate >5% on active WO
if (!offline && good != null && scrap != null && good + scrap > 0) {
const scrapPct = (scrap / (good + scrap)) * 100;
if (scrapPct > 10) {
score += 40;
reasons.push(t("overview.attention.scrapHigh", { value: scrapPct.toFixed(1) }));
} else if (scrapPct > 5) {
score += 20;
reasons.push(t("overview.attention.scrapMod", { value: scrapPct.toFixed(1) }));
}
}
// Trigger 5: availability collapse (often means undeclared stops)
if (!offline && availability != null && availability < 60) {
score += 25;
reasons.push(t("overview.attention.availLow", { value: availability.toFixed(0) }));
}
return { machine: m, offline, oee, score, reasons };
}) })
.filter((x) => x.score > 0) .filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.slice(0, 6); .slice(0, 6);
return list; return list;
}, [machines]); }, [machines, t]);
return ( return (
<div className="p-4 sm:p-6"> <div className="p-4 sm:p-6">
@@ -346,8 +392,12 @@ export default function OverviewClient({
<div className="text-sm text-zinc-400">{t("overview.noUrgent")}</div> <div className="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{attention.map(({ machine, offline, oee }) => ( {attention.map(({ machine, offline, oee, reasons }) => (
<div key={machine.id} className="rounded-xl border border-white/10 bg-black/20 p-3"> <Link
key={machine.id}
href={`/recap/${machine.id}`}
className="block rounded-xl border border-white/10 bg-black/20 p-3 hover:border-white/20 hover:bg-black/30 transition"
>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{machine.name}</div> <div className="truncate text-sm font-semibold text-white">{machine.name}</div>
@@ -359,7 +409,7 @@ export default function OverviewClient({
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))} {secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
</div> </div>
</div> </div>
<div className="mt-2 flex items-center gap-2 text-xs"> <div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span <span
className={`rounded-full px-2 py-0.5 ${ className={`rounded-full px-2 py-0.5 ${
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300" offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
@@ -367,13 +417,20 @@ export default function OverviewClient({
> >
{offline ? t("overview.status.offline") : t("overview.status.online")} {offline ? t("overview.status.offline") : t("overview.status.online")}
</span> </span>
{oee != null && ( {oee != null && !offline && (
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300"> <span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
OEE {fmtPct(oee)} OEE {fmtPct(oee)}
</span> </span>
)} )}
</div> </div>
</div> {reasons.length > 0 && (
<ul className="mt-2 space-y-0.5 text-[11px] text-zinc-400">
{reasons.map((r, i) => (
<li key={i}>· {r}</li>
))}
</ul>
)}
</Link>
))} ))}
</div> </div>
)} )}

View File

@@ -219,6 +219,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
hasData={timelineHasData} hasData={timelineHasData}
loading={timelineLoading} loading={timelineLoading}
locale={locale} locale={locale}
rangeMode={initialData.range.mode}
/> />
</div> </div>

View File

@@ -298,25 +298,68 @@ export async function GET(req: NextRequest) {
scrapRate: [], scrapRate: [],
}; };
type TsBucket = {
oeeSum: number; oeeCount: number;
availSum: number; availCount: number;
perfSum: number; perfCount: number;
qualSum: number; qualCount: number;
goodSum: number; scrapSum: number;
anyProduction: boolean;
};
const tsBuckets = new Map<string, TsBucket>();
for (const k of kpiRows) { for (const k of kpiRows) {
const t = k.ts.toISOString(); const t = k.ts.toISOString();
if (!isProductionSnapshot(k.trackingEnabled, k.productionStarted)) { let b = tsBuckets.get(t);
// Preserve timeline gaps across non-production windows for OEE-family charting. if (!b) {
b = {
oeeSum: 0, oeeCount: 0,
availSum: 0, availCount: 0,
perfSum: 0, perfCount: 0,
qualSum: 0, qualCount: 0,
goodSum: 0, scrapSum: 0,
anyProduction: false,
};
tsBuckets.set(t, b);
}
const isProd = isProductionSnapshot(k.trackingEnabled, k.productionStarted);
if (isProd) {
b.anyProduction = true;
const oee = safeNum(k.oee);
if (oee != null) { b.oeeSum += Number(oee); b.oeeCount += 1; }
const avail = safeNum(k.availability);
if (avail != null) { b.availSum += Number(avail); b.availCount += 1; }
const perf = safeNum(k.performance);
if (perf != null) { b.perfSum += Number(perf); b.perfCount += 1; }
const qual = safeNum(k.quality);
if (qual != null) { b.qualSum += Number(qual); b.qualCount += 1; }
}
const good = safeNum(k.good);
const scrap = safeNum(k.scrap);
if (good != null) b.goodSum += Number(good);
if (scrap != null) b.scrapSum += Number(scrap);
}
// Iterate sorted ts. kpiRows already orderBy ts asc, but Map insertion
// order matches that, so spreading keys preserves order.
for (const [t, b] of tsBuckets) {
if (!b.anyProduction) {
// No machine producing at this ts -> gap, same as before.
trend.oee.push({ t, v: null }); trend.oee.push({ t, v: null });
trend.availability.push({ t, v: null }); trend.availability.push({ t, v: null });
trend.performance.push({ t, v: null }); trend.performance.push({ t, v: null });
trend.quality.push({ t, v: null }); trend.quality.push({ t, v: null });
} else { } else {
trend.oee.push({ t, v: safeNum(k.oee) != null ? Number(k.oee) : null }); trend.oee.push({ t, v: b.oeeCount ? b.oeeSum / b.oeeCount : null });
trend.availability.push({ t, v: safeNum(k.availability) != null ? Number(k.availability) : null }); trend.availability.push({ t, v: b.availCount ? b.availSum / b.availCount : null });
trend.performance.push({ t, v: safeNum(k.performance) != null ? Number(k.performance) : null }); trend.performance.push({ t, v: b.perfCount ? b.perfSum / b.perfCount : null });
trend.quality.push({ t, v: safeNum(k.quality) != null ? Number(k.quality) : null }); trend.quality.push({ t, v: b.qualCount ? b.qualSum / b.qualCount : null });
} }
const total = b.goodSum + b.scrapSum;
const good = safeNum(k.good); if (total > 0) {
const scrap = safeNum(k.scrap); trend.scrapRate.push({ t, v: (b.scrapSum / total) * 100 });
if (good != null && scrap != null && good + scrap > 0) {
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
} }
} }
const cycleRowsStart = nowMs(); const cycleRowsStart = nowMs();

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import type { RecapTimelineSegment } from "@/lib/recap/types"; import type { RecapRangeMode, RecapTimelineSegment } from "@/lib/recap/types";
import { import {
computeWidths, computeWidths,
formatDuration, formatDuration,
@@ -19,6 +19,7 @@ type Props = {
locale: string; locale: string;
hasData?: boolean; hasData?: boolean;
loading?: boolean; loading?: boolean;
rangeMode?: RecapRangeMode;
}; };
export default function RecapFullTimeline({ export default function RecapFullTimeline({
@@ -28,6 +29,7 @@ export default function RecapFullTimeline({
locale, locale,
hasData = false, hasData = false,
loading = false, loading = false,
rangeMode,
}: Props) { }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const startMs = new Date(rangeStart).getTime(); const startMs = new Date(rangeStart).getTime();
@@ -36,10 +38,19 @@ export default function RecapFullTimeline({
const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : []; const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT); const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
const rangeSuffix =
rangeMode === "shift"
? t("recap.range.shiftCurrent")
: rangeMode === "yesterday"
? t("recap.range.yesterday")
: rangeMode === "custom"
? t("recap.range.custom")
: t("recap.range.24h");
const titleText = `${t("recap.timeline.title")} · ${rangeSuffix}`;
return ( return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4"> <div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.timeline.title")}</div> <div className="mb-3 text-sm font-semibold text-white">{titleText}</div>
{loading ? ( {loading ? (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="min-w-[560px]"> <div className="min-w-[560px]">

View File

@@ -59,7 +59,7 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
async function loadTimeline() { async function loadTimeline() {
try { try {
const res = await fetch( const res = await fetch(
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=30`, `/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`,
{ cache: "no-store" } { cache: "no-store" }
); );
const json = await res.json().catch(() => null); const json = await res.json().catch(() => null);

View File

@@ -19,7 +19,7 @@ type Props = {
hasData?: boolean; hasData?: boolean;
}; };
const MIN_SEGMENT_PCT = 1.5; const MIN_SEGMENT_PCT = 0.5;
export default function RecapMiniTimeline({ export default function RecapMiniTimeline({
rangeStart, rangeStart,

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { formatRecapProgressPercent } from "@/lib/recap/progressDisplay";
import type { RecapSkuRow } from "@/lib/recap/types"; import type { RecapSkuRow } from "@/lib/recap/types";
type Props = { type Props = {
@@ -9,7 +8,7 @@ type Props = {
}; };
export default function RecapProductionBySku({ rows }: Props) { export default function RecapProductionBySku({ rows }: Props) {
const { t, locale } = useI18n(); const { t } = useI18n();
return ( return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4"> <div className="rounded-2xl border border-white/10 bg-black/40 p-4">
@@ -24,25 +23,17 @@ export default function RecapProductionBySku({ rows }: Props) {
<tr className="border-b border-white/10 text-left text-xs uppercase tracking-wide text-zinc-400"> <tr className="border-b border-white/10 text-left text-xs uppercase tracking-wide text-zinc-400">
<th className="py-2 pr-3">{t("recap.production.sku")}</th> <th className="py-2 pr-3">{t("recap.production.sku")}</th>
<th className="py-2 pr-3">{t("recap.production.good")}</th> <th className="py-2 pr-3">{t("recap.production.good")}</th>
<th className="py-2 pr-3">{t("recap.production.scrap")}</th> <th className="py-2">{t("recap.production.scrap")}</th>
<th className="py-2 pr-3">{t("recap.production.target")}</th>
<th className="py-2">{t("recap.production.progress")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.slice(0, 10).map((row) => { {rows.slice(0, 10).map((row) => (
const progress = <tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5">
row.progressPct == null ? "—" : formatRecapProgressPercent(row.progressPct, locale); <td className="py-2 pr-3">{row.sku}</td>
return ( <td className="py-2 pr-3">{row.good}</td>
<tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5"> <td className={`py-2 ${row.scrap > 0 ? "text-red-300" : ""}`}>{row.scrap}</td>
<td className="py-2 pr-3">{row.sku}</td> </tr>
<td className="py-2 pr-3">{row.good}</td> ))}
<td className={`py-2 pr-3 ${row.scrap > 0 ? "text-red-300" : ""}`}>{row.scrap}</td>
<td className="py-2 pr-3">{row.target ?? "--"}</td>
<td className="py-2 text-emerald-300">{progress}</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -17,7 +17,7 @@ const COLORS: Record<RecapTimelineSegment["type"], string> = {
"slow-cycle": "bg-amber-500 text-black", "slow-cycle": "bg-amber-500 text-black",
idle: "bg-zinc-600 text-zinc-300", idle: "bg-zinc-600 text-zinc-300",
}; };
const MIN_SEGMENT_PCT = 1.5; const MIN_SEGMENT_PCT = 0.3;
const LABEL_MIN_PCT = 5; const LABEL_MIN_PCT = 5;
function fmtTime(valueMs: number, locale: string) { function fmtTime(valueMs: number, locale: string) {

View File

@@ -10,7 +10,7 @@ export const TIMELINE_COLORS: Record<RecapTimelineSegment["type"], string> = {
}; };
export const LABEL_MIN_WIDTH_PCT = 5; export const LABEL_MIN_WIDTH_PCT = 5;
export const SEGMENT_MIN_WIDTH_PCT = 1.5; export const SEGMENT_MIN_WIDTH_PCT = 0.3;
export function formatTime(valueMs: number, locale: string) { export function formatTime(valueMs: number, locale: string) {
return new Date(valueMs).toLocaleTimeString(locale, { return new Date(valueMs).toLocaleTimeString(locale, {

252
fix5.md Normal file
View File

@@ -0,0 +1,252 @@
Filter context noted: turno actual + ayer both flow through resolveDetailRange → getRecapDataCached → same loadWorkOrderCounterRows lifetime bug. Patch 1 below fixes lifetime numbers across all filter modes. The "Timeline 24h" label is hardcoded i18n key recap.timeline.title regardless of selected range — separate cosmetic bug. Patch list at end.
PATCH 1 — Window-scoped SKU + production totals
File: lib/recap/getRecapData.ts
Replaces: lines ~642678 (the for (const row of machineWorkOrderCounters) loop, the openWorkOrders ensure loop, and the latestTelemetry ensure block)
BEFORE:
ts 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 target = safeNum(row.targetQty);
const skuAgg = ensureAuthoritativeSku(row.sku, target, false);
skuAgg.good += safeGood;
skuAgg.scrap += safeScrap;
goodParts += safeGood;
scrapParts += 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);
}
for (const wo of openWorkOrders) {
ensureAuthoritativeSku(normalizeToken(wo.sku) || null);
}
if (latestTelemetry?.sku) {
ensureAuthoritativeSku(latestTelemetry.sku);
}
AFTER:
ts // Step 1: WO-level LIFETIME progress map.
// Used downstream for completed-WO totals (goodParts/durationHrs) and active-WO progressPct,
// both of which intentionally want lifetime, not window-scoped, values.
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 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);
}
// Step 2: WINDOW-SCOPED production totals + per-SKU breakdown from in-window cycle deltas.
// dedupedCycles is already filtered by ts >= start && ts <= end at the Prisma query level.
// Each cycle row contributes its own goodDelta/scrapDelta to the SKU it belongs to.
for (const cycle of dedupedCycles) {
const skuRaw = normalizeToken(cycle.sku);
const g = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
const s = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
// Count the cycle row toward total cycles regardless of SKU (timing-only cycles still happened).
authoritativeCycleCount += 1;
if (g === 0 && s === 0) continue; // no production to attribute
goodParts += g;
scrapParts += s;
if (!skuRaw) continue; // production exists but no SKU tag — count totals, skip SKU table row
const skuAgg = ensureAuthoritativeSku(skuRaw, null, true);
skuAgg.good += g;
skuAgg.scrap += s;
}
What changes for the user:
BUENAS / SCRAP / SKU table = in-window only
Empty SKUs (open WOs that produced nothing in window, latest telemetry SKU) no longer pad the table
Completed WO list, active WO progress%, mold change logic = unchanged (still use lifetime via authoritativeWorkOrderProgress)
PATCH 2 — Unify machine-detail timeline range to 24h
File: app/(app)/machines/[machineId]/MachineDetailClient.tsx
Change 1 — function rename + range: find getMinuteFlooredOneHourRange (around line 365373):
BEFORE:
tsfunction getMinuteFlooredOneHourRange() {
const endMs = Math.floor(Date.now() / 60000) * 60000;
return {
startMs: endMs - 60 * 60 * 1000,
endMs,
};
}
AFTER:
tsfunction getMinuteFlooredDefaultRange() {
const endMs = Math.floor(Date.now() / 60000) * 60000;
return {
startMs: endMs - 24 * 60 * 60 * 1000,
endMs,
};
}
Change 2 — call sites: there are two of them in MachineActivityTimeline (line ~388 inside loadTimeline, line ~427 for the fallback). Replace both:
BEFORE:
tsconst range = getMinuteFlooredOneHourRange();
tsconst fallbackRange = getMinuteFlooredOneHourRange();
AFTER:
tsconst range = getMinuteFlooredDefaultRange();
tsconst fallbackRange = getMinuteFlooredDefaultRange();
Change 3 — UI label: line ~447:
BEFORE:
tsx<div className="text-xs text-zinc-400">1h</div>
AFTER:
tsx<div className="text-xs text-zinc-400">24h</div>
After this, machine detail timeline = same backend, same range, same input as recap detail timeline → identical content (modulo cache age).
PATCH 3 — Dynamic timeline title that reflects the active filter
Reuses existing recap.range.* translation keys. No i18n file changes needed.
File A: components/recap/RecapFullTimeline.tsx
Change 1 — imports + type:
BEFORE (lines 122):
tsx"use client";
import type { RecapTimelineSegment } from "@/lib/recap/types";
import {
computeWidths,
formatDuration,
formatTime,
LABEL_MIN_WIDTH_PCT,
normalizeTimelineSegments,
SEGMENT_MIN_WIDTH_PCT,
TIMELINE_COLORS,
} from "@/components/recap/timelineRender";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
rangeStart: string;
rangeEnd: string;
segments: RecapTimelineSegment[];
locale: string;
hasData?: boolean;
loading?: boolean;
};
AFTER:
tsx"use client";
import type { RecapRangeMode, RecapTimelineSegment } from "@/lib/recap/types";
import {
computeWidths,
formatDuration,
formatTime,
LABEL_MIN_WIDTH_PCT,
normalizeTimelineSegments,
SEGMENT_MIN_WIDTH_PCT,
TIMELINE_COLORS,
} from "@/components/recap/timelineRender";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
rangeStart: string;
rangeEnd: string;
segments: RecapTimelineSegment[];
locale: string;
hasData?: boolean;
loading?: boolean;
rangeMode?: RecapRangeMode;
};
Change 2 — destructure prop + render dynamic title:
BEFORE (lines 2442):
tsxexport default function RecapFullTimeline({
rangeStart,
rangeEnd,
segments,
locale,
hasData = false,
loading = false,
}: 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 = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.timeline.title")}</div>
AFTER:
tsxexport default function RecapFullTimeline({
rangeStart,
rangeEnd,
segments,
locale,
hasData = false,
loading = false,
rangeMode,
}: 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 = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
const rangeSuffix =
rangeMode === "shift"
? t("recap.range.shiftCurrent")
: rangeMode === "yesterday"
? t("recap.range.yesterday")
: rangeMode === "custom"
? t("recap.range.custom")
: t("recap.range.24h");
const titleText = `${t("recap.timeline.title")} · ${rangeSuffix}`;
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{titleText}</div>
File B: app/(app)/recap/[machineId]/RecapDetailClient.tsx
BEFORE (around lines 215222):
tsx <RecapFullTimeline
rangeStart={timelineStart}
rangeEnd={timelineEnd}
segments={timelineSegments}
hasData={timelineHasData}
loading={timelineLoading}
locale={locale}
/>
AFTER:
tsx <RecapFullTimeline
rangeStart={timelineStart}
rangeEnd={timelineEnd}
segments={timelineSegments}
hasData={timelineHasData}
loading={timelineLoading}
locale={locale}
rangeMode={initialData.range.mode}
/>
Optional bonus — change i18n value: lib/i18n/es-MX.json and lib/i18n/en.json, find key recap.timeline.title and change value from "Timeline 24h" (or whatever it currently is) to just "Timeline". The dynamic suffix will append the actual range. If you don't strip the "24h" from the value, the title will read "Timeline 24h · Ayer" when ayer is selected — still better than current, but cleaner if stripped.

107
fix6.md Normal file
View File

@@ -0,0 +1,107 @@
Patch 1 — Apply settings + update UI function node (PRIMARY)
Node: Apply settings + update UI (function node)
Action: Replace the entire normalizeCatalogItems definition.
FIND this block (lines ~5876 of the function):
javascriptconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {
if (!Array.isArray(list)) return [];
return list
.map((c, idx) => {
const categoryId = String(c.id || c.categoryId || ("cat_" + idx));
const categoryLabel = String(c.label || c.categoryLabel || (fallbackLabelPrefix + " " + (idx + 1)));
const detailsRaw = Array.isArray(c.children) ? c.children : (Array.isArray(c.details) ? c.details : []);
const details = detailsRaw.map((d, jdx) => ({
id: String(d.id || d.detailId || (categoryId + "_d" + jdx)),
label: String(d.label || d.detailLabel || ("Detalle " + (jdx + 1)))
}));
return {
id: categoryId,
label: categoryLabel,
children: details
};
})
.filter((c) => c.label && c.children.length > 0);
};
REPLACE with:
javascript// ============================================================
// CATALOG SANITIZER
// Defense against leaked markdown/spec text being stored as
// catalog labels in Control Tower. Rejects entries whose label
// looks like documentation/notes rather than a real reason.
// Tune MAX_LABEL_LEN if your real labels are longer.
// ============================================================
const MAX_LABEL_LEN = 40;
const isCleanLabel = (s) => {
if (typeof s !== "string") return false;
const t = s.trim();
if (!t) return false;
if (t.length > MAX_LABEL_LEN) return false; // sentence-length text
if (/[\r\n\t]/.test(t)) return false; // multi-line content
if (/^[-*#>|`\[\]]/.test(t)) return false; // markdown leaders: - * # > | ` [ ]
if (/\*\*|__|```|~~~|###/.test(t)) return false; // markdown bold/code/heading
if (/[(\[<{][^)\]>}]*$/.test(t)) return false; // unbalanced opening bracket → truncated
if (/=/.test(t)) return false; // code-like assignment (e.g. type=event)
return true;
};
const normalizeCatalogItems = (list, fallbackLabelPrefix) => {
if (!Array.isArray(list)) return [];
const dropped = [];
const cleaned = list
.map((c, idx) => {
const categoryId = String(c.id || c.categoryId || ("cat_" + idx));
const categoryLabel = String(
c.label || c.categoryLabel || (fallbackLabelPrefix + " " + (idx + 1))
).trim();
const detailsRaw = Array.isArray(c.children)
? c.children
: (Array.isArray(c.details) ? c.details : []);
const details = detailsRaw
.map((d, jdx) => ({
id: String(d.id || d.detailId || (categoryId + "_d" + jdx)),
label: String(d.label || d.detailLabel || ("Detalle " + (jdx + 1))).trim()
}))
.filter((d) => {
if (isCleanLabel(d.label)) return true;
dropped.push("detail<" + categoryLabel.slice(0, 20) + ">: " + d.label.slice(0, 50));
return false;
});
return { id: categoryId, label: categoryLabel, children: details };
})
.filter((c) => {
if (!isCleanLabel(c.label)) {
dropped.push("category: " + c.label.slice(0, 50));
return false;
}
if (c.children.length === 0) {
dropped.push("empty: " + c.label.slice(0, 50));
return false;
}
return true;
});
if (dropped.length > 0) {
node.warn(
"[CATALOG SANITIZER " + fallbackLabelPrefix + "] Dropped " +
dropped.length + " polluted entries:\n - " +
dropped.slice(0, 15).join("\n - ") +
(dropped.length > 15 ? "\n ... (+" + (dropped.length - 15) + " more)" : "")
);
}
return cleaned;
};
Side effects:
Function signature unchanged → no other code in this node needs to change.
The two call sites (incomingCatalog.downtime, incomingCatalog.scrap) work identically.
node.warn will fire on every settings sync that has dirty data — this is intentional so you see when CT pushes garbage.
A category whose children are all polluted will be dropped (it'd be useless anyway).
dropped only logs first 15 to avoid debug-pane spam.
Risk on legit data: MAX_LABEL_LEN = 40 will reject labels longer than 40 chars. If your real catalog has labels like "Falla mecánica del extrusor principal con sensor" (49), bump this to 60. The shortest known false-negative in your current data ("Tap Acknowledge on anomaly panel", 32 chars) still slips through — see Patch 2 below or upstream cleanup.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -183,7 +183,7 @@
"recap.banner.offline": "No signal for {min} min", "recap.banner.offline": "No signal for {min} min",
"recap.banner.ongoingStop": "Machine stopped for {min} min", "recap.banner.ongoingStop": "Machine stopped for {min} min",
"recap.banner.stopped": "Machine stopped for {minutes} min", "recap.banner.stopped": "Machine stopped for {minutes} min",
"recap.timeline.title": "24h timeline", "recap.timeline.title": "Timeline",
"recap.timeline.noData": "No timeline data", "recap.timeline.noData": "No timeline data",
"recap.timeline.type.production": "Production", "recap.timeline.type.production": "Production",
"recap.timeline.type.moldChange": "Mold change", "recap.timeline.type.moldChange": "Mold change",
@@ -255,6 +255,9 @@
"machine.detail.bucket.unknown": "Unknown", "machine.detail.bucket.unknown": "Unknown",
"machine.detail.activity.title": "Machine Activity Timeline", "machine.detail.activity.title": "Machine Activity Timeline",
"machine.detail.activity.subtitle": "Real-time analysis of production cycles", "machine.detail.activity.subtitle": "Real-time analysis of production cycles",
"machine.detail.activity.windowBadge": "1h",
"machine.detail.activity.windowModalTitle": "Timeline window",
"machine.detail.activity.windowModalBody": "This timeline always shows the last 1 hour of machine activity.",
"machine.detail.activity.noData": "No timeline data yet.", "machine.detail.activity.noData": "No timeline data yet.",
"machine.detail.tooltip.cycle": "Cycle: {label}", "machine.detail.tooltip.cycle": "Cycle: {label}",
"machine.detail.tooltip.duration": "Duration", "machine.detail.tooltip.duration": "Duration",
@@ -621,5 +624,12 @@
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.", "settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
"settings.modules.screenless.title": "Screenless mode", "settings.modules.screenless.title": "Screenless mode",
"settings.modules.screenless.helper": "Hide the Downtime module from navigation (for plants without Node-RED reason capture).", "settings.modules.screenless.helper": "Hide the Downtime module from navigation (for plants without Node-RED reason capture).",
"settings.modules.note": "This setting is org-wide." "settings.modules.note": "This setting is org-wide.",
"overview.attention.offline": "Offline — no heartbeat",
"overview.attention.stopped": "Currently stopped",
"overview.attention.oeeCritical": "OEE critical: {value}%",
"overview.attention.oeeLow": "OEE low: {value}%",
"overview.attention.scrapHigh": "Scrap rate high: {value}%",
"overview.attention.scrapMod": "Scrap rate elevated: {value}%",
"overview.attention.availLow": "Availability low: {value}%"
} }

View File

@@ -104,6 +104,13 @@
"overview.event.macrostop": "macroparo", "overview.event.macrostop": "macroparo",
"overview.event.microstop": "microparo", "overview.event.microstop": "microparo",
"overview.event.slow-cycle": "ciclo lento", "overview.event.slow-cycle": "ciclo lento",
"overview.attention.offline": "Sin señal",
"overview.attention.stopped": "Detenida ahora",
"overview.attention.oeeCritical": "OEE crítica: {value}%",
"overview.attention.oeeLow": "OEE baja: {value}%",
"overview.attention.scrapHigh": "Scrap alto: {value}%",
"overview.attention.scrapMod": "Scrap elevado: {value}%",
"overview.attention.availLow": "Disponibilidad baja: {value}%",
"overview.status.offline": "FUERA DE LÍNEA", "overview.status.offline": "FUERA DE LÍNEA",
"overview.status.online": "EN LÍNEA", "overview.status.online": "EN LÍNEA",
"overview.recap.title": "Resumen diario de turno", "overview.recap.title": "Resumen diario de turno",
@@ -183,7 +190,7 @@
"recap.banner.offline": "Sin señal hace {min} min", "recap.banner.offline": "Sin señal hace {min} min",
"recap.banner.ongoingStop": "Máquina detenida hace {min} min", "recap.banner.ongoingStop": "Máquina detenida hace {min} min",
"recap.banner.stopped": "Máquina detenida hace {minutes} min", "recap.banner.stopped": "Máquina detenida hace {minutes} min",
"recap.timeline.title": "Timeline 24h", "recap.timeline.title": "Timeline",
"recap.timeline.noData": "Sin datos de línea de tiempo", "recap.timeline.noData": "Sin datos de línea de tiempo",
"recap.timeline.type.production": "Producción", "recap.timeline.type.production": "Producción",
"recap.timeline.type.moldChange": "Cambio de molde", "recap.timeline.type.moldChange": "Cambio de molde",
@@ -255,6 +262,9 @@
"machine.detail.bucket.unknown": "Desconocido", "machine.detail.bucket.unknown": "Desconocido",
"machine.detail.activity.title": "Línea de tiempo de actividad", "machine.detail.activity.title": "Línea de tiempo de actividad",
"machine.detail.activity.subtitle": "Análisis en tiempo real de ciclos de producción", "machine.detail.activity.subtitle": "Análisis en tiempo real de ciclos de producción",
"machine.detail.activity.windowBadge": "1h",
"machine.detail.activity.windowModalTitle": "Ventana de timeline",
"machine.detail.activity.windowModalBody": "Este timeline siempre muestra la última 1 hora de actividad de la máquina.",
"machine.detail.activity.noData": "Sin datos de línea de tiempo.", "machine.detail.activity.noData": "Sin datos de línea de tiempo.",
"machine.detail.tooltip.cycle": "Ciclo: {label}", "machine.detail.tooltip.cycle": "Ciclo: {label}",
"machine.detail.tooltip.duration": "Duración", "machine.detail.tooltip.duration": "Duración",

View File

@@ -230,45 +230,6 @@ function moldStartMs(data: unknown, fallbackTs: Date) {
return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime()); return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime());
} }
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[];
}) {
if (!params.machineIds.length) return [] as WorkOrderCounterRow[];
return prisma.machineWorkOrder.findMany({
where: {
orgId: params.orgId,
machineId: { in: params.machineIds },
},
select: {
machineId: true,
workOrderId: true,
sku: true,
targetQty: true,
status: true,
createdAt: true,
updatedAt: true,
goodParts: true,
scrapParts: true,
cycleCount: true,
},
});
}
export function parseRecapQuery(input: { export function parseRecapQuery(input: {
machineId?: string | null; machineId?: string | null;
start?: string | null; start?: string | null;
@@ -306,7 +267,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
const machineIds = machines.map((m) => m.id); const machineIds = machines.map((m) => m.id);
const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS); const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS);
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, workOrderCounterRowsRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] = const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
await Promise.all([ await Promise.all([
prisma.orgSettings.findUnique({ prisma.orgSettings.findUnique({
where: { orgId: params.orgId }, where: { orgId: params.orgId },
@@ -401,10 +362,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
updatedAt: true, updatedAt: true,
}, },
}), }),
loadWorkOrderCounterRows({
orgId: params.orgId,
machineIds,
}),
prisma.machineHeartbeat.findMany({ prisma.machineHeartbeat.findMany({
where: { where: {
orgId: params.orgId, orgId: params.orgId,
@@ -473,7 +430,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
const eventsByMachine = new Map<string, typeof events>(); const eventsByMachine = new Map<string, typeof events>();
const reasonsByMachine = new Map<string, typeof reasons>(); const reasonsByMachine = new Map<string, typeof reasons>();
const workOrdersByMachine = new Map<string, typeof workOrdersRaw>(); const workOrdersByMachine = new Map<string, typeof workOrdersRaw>();
const workOrderCountersByMachine = new Map<string, WorkOrderCounterRow[]>();
const hbRangeByMachine = new Map<string, typeof hbRange>(); const hbRangeByMachine = new Map<string, typeof hbRange>();
const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row])); const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row]));
const moldEventsByMachine = new Map<string, typeof moldEventsRaw>(); const moldEventsByMachine = new Map<string, typeof moldEventsRaw>();
@@ -508,12 +464,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
workOrdersByMachine.set(row.machineId, list); 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) { for (const row of hbRange) {
const list = hbRangeByMachine.get(row.machineId) ?? []; const list = hbRangeByMachine.get(row.machineId) ?? [];
list.push(row); list.push(row);
@@ -532,7 +482,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
const machineEvents = eventsByMachine.get(machine.id) ?? []; const machineEvents = eventsByMachine.get(machine.id) ?? [];
const machineReasons = reasonsByMachine.get(machine.id) ?? []; const machineReasons = reasonsByMachine.get(machine.id) ?? [];
const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? []; const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? [];
const machineWorkOrderCounters = workOrderCountersByMachine.get(machine.id) ?? [];
const machineHbRange = hbRangeByMachine.get(machine.id) ?? []; const machineHbRange = hbRangeByMachine.get(machine.id) ?? [];
const latestHb = hbLatestByMachine.get(machine.id) ?? null; const latestHb = hbLatestByMachine.get(machine.id) ?? null;
const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? []; const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? [];
@@ -599,7 +548,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
const openWorkOrders = machineWorkOrdersSorted.filter( const openWorkOrders = machineWorkOrdersSorted.filter(
(wo) => String(wo.status).toUpperCase() !== "COMPLETED" (wo) => String(wo.status).toUpperCase() !== "COMPLETED"
); );
const authoritativeWorkOrderProgress = new Map< const rangeWorkOrderProgress = new Map<
string, string,
{ goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null } { goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null }
>(); >();
@@ -639,58 +588,45 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
return created; return created;
}; };
for (const row of machineWorkOrderCounters) { for (const cycle of dedupedCycles) {
const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0)); const skuRaw = normalizeToken(cycle.sku);
const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0)); const g = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0)); const s = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
const target = safeNum(row.targetQty); const woKey = workOrderKey(cycle.workOrderId);
authoritativeCycleCount += 1;
const skuAgg = ensureAuthoritativeSku(row.sku, target, false); if (g === 0 && s === 0) continue;
skuAgg.good += safeGood; goodParts += g;
skuAgg.scrap += safeScrap; scrapParts += s;
if (woKey) {
goodParts += safeGood; const progress = rangeWorkOrderProgress.get(woKey) ?? {
scrapParts += safeScrap; goodParts: 0,
authoritativeCycleCount += safeCycleCount; scrapParts: 0,
cycleCount: 0,
const woKey = workOrderKey(row.workOrderId); firstTs: null,
if (!woKey) continue; lastTs: null,
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);
}
for (const wo of openWorkOrders) {
ensureAuthoritativeSku(normalizeToken(wo.sku) || null);
}
if (latestTelemetry?.sku) {
ensureAuthoritativeSku(latestTelemetry.sku);
}
const bySku = [...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,
}; };
}) progress.goodParts += g;
progress.scrapParts += s;
progress.cycleCount += 1;
if (!progress.firstTs || cycle.ts < progress.firstTs) progress.firstTs = cycle.ts;
if (!progress.lastTs || cycle.ts > progress.lastTs) progress.lastTs = cycle.ts;
rangeWorkOrderProgress.set(woKey, progress);
}
if (!skuRaw) continue;
const skuAgg = ensureAuthoritativeSku(skuRaw, null, true);
skuAgg.good += g;
skuAgg.scrap += s;
}
const bySku = [...authoritativeSkuMap.values()]
.map((row) => ({
machineName: row.machineName,
sku: row.sku,
good: row.good,
scrap: row.scrap,
target: null as number | null,
progressPct: null as number | null,
}))
.sort((a, b) => b.good - a.good); .sort((a, b) => b.good - a.good);
const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime()); const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
@@ -762,7 +698,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
.filter((wo) => String(wo.status).toUpperCase() === "COMPLETED") .filter((wo) => String(wo.status).toUpperCase() === "COMPLETED")
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end) .filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
.map((wo) => { .map((wo) => {
const progress = authoritativeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? { const progress = rangeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? {
goodParts: 0, goodParts: 0,
scrapParts: 0, scrapParts: 0,
cycleCount: 0, cycleCount: 0,
@@ -801,19 +737,15 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
let activeProgressPct: number | null = null; let activeProgressPct: number | null = null;
let activeStartedAt: string | null = null; let activeStartedAt: string | null = null;
if (activeWorkOrderId) { if (activeWorkOrderId) {
const authoritativeProgress = activeWorkOrderKey const rangeProgress = activeWorkOrderKey ? rangeWorkOrderProgress.get(activeWorkOrderKey) ?? null : null;
? authoritativeWorkOrderProgress.get(activeWorkOrderKey) ?? null const producedForProgress = rangeProgress
: null; ? rangeProgress.goodParts + rangeProgress.scrapParts
const producedForProgress = authoritativeProgress
? authoritativeProgress.goodParts + authoritativeProgress.scrapParts
: 0; : 0;
const targetQty = safeNum(activeTargetSource?.targetQty); const targetQty = safeNum(activeTargetSource?.targetQty);
if (targetQty && targetQty > 0) { if (targetQty && targetQty > 0) {
activeProgressPct = round2((producedForProgress / targetQty) * 100); activeProgressPct = round2((producedForProgress / targetQty) * 100);
} }
activeStartedAt = toIso( activeStartedAt = toIso(rangeProgress?.firstTs ?? latestTelemetry?.ts ?? null);
authoritativeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null
);
} }
const firstProductionMsAfterMoldStart = (startMs: number) => { const firstProductionMsAfterMoldStart = (startMs: number) => {

View File

@@ -1,4 +1,4 @@
/** /**
* Client-safe recap thresholds. Kept in sync with OFFLINE logic in lib/recap/redesign.ts. * Client-safe recap thresholds. Kept in sync with OFFLINE logic in lib/recap/redesign.ts.
*/ */
export const RECAP_HEARTBEAT_STALE_MS = 10 * 60 * 1000; export const RECAP_HEARTBEAT_STALE_MS = 5 * 60 * 1000;

View File

@@ -28,6 +28,7 @@ type DetailRangeInput = {
const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS; const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000; const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
const RECAP_CACHE_TTL_SEC = 60; const RECAP_CACHE_TTL_SEC = 60;
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = { const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
@@ -213,7 +214,10 @@ async function loadTimelineRowsForMachines(params: {
where: { where: {
orgId: params.orgId, orgId: params.orgId,
machineId: { in: params.machineIds }, machineId: { in: params.machineIds },
ts: { gte: params.start, lte: params.end }, ts: {
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
lte: params.end,
},
}, },
orderBy: [{ machineId: "asc" }, { ts: "asc" }], orderBy: [{ machineId: "asc" }, { ts: "asc" }],
select: { select: {
@@ -338,7 +342,7 @@ async function computeRecapSummary(params: { orgId: string; hours: number }) {
segments, segments,
rangeStart: start, rangeStart: start,
rangeEnd: end, rangeEnd: end,
maxSegments: 30, maxSegments: 60,
}); });
return toSummaryMachine({ return toSummaryMachine({
@@ -443,21 +447,25 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
minutes: startMin % 60, minutes: startMin % 60,
timeZone, timeZone,
}); });
const end = zonedToUtcDate({ const shiftEndUtc = zonedToUtcDate({
...endDate, ...endDate,
hours: Math.floor(endMin / 60), hours: Math.floor(endMin / 60),
minutes: endMin % 60, minutes: endMin % 60,
timeZone, timeZone,
}); });
if (end <= start) continue; if (shiftEndUtc <= start) continue;
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
// Without cap:
// - timeline fills future minutes with idle (visual lie)
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
// even on a machine producing right now
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
return { return {
hasEnabledShifts: true, hasEnabledShifts: true,
range: { range: { start, end },
start,
end,
},
}; };
} }

View File

@@ -751,22 +751,24 @@ export function compressTimelineSegments(input: {
const bucketEnd = i === maxSegments - 1 ? rangeEndMs : Math.trunc(rangeStartMs + (i + 1) * bucketMs); const bucketEnd = i === maxSegments - 1 ? rangeEndMs : Math.trunc(rangeStartMs + (i + 1) * bucketMs);
if (bucketEnd <= bucketStart) continue; if (bucketEnd <= bucketStart) continue;
let winner: RecapTimelineSegment | null = null; let winner: RecapTimelineSegment | null = null;
let winnerOverlap = -1; let winnerPriority = -1;
let winnerOverlap = -1;
for (const segment of normalized) { for (const segment of normalized) {
const overlapStart = Math.max(bucketStart, segment.startMs); const overlapStart = Math.max(bucketStart, segment.startMs);
const overlapEnd = Math.min(bucketEnd, segment.endMs); const overlapEnd = Math.min(bucketEnd, segment.endMs);
if (overlapEnd <= overlapStart) continue; if (overlapEnd <= overlapStart) continue;
const overlap = overlapEnd - overlapStart; const overlap = overlapEnd - overlapStart;
const priorityBonus = segmentPriority(segment.type) / 1000; const priority = segmentPriority(segment.type);
const score = overlap + priorityBonus;
if (score > winnerOverlap) { if (priority > winnerPriority || (priority === winnerPriority && overlap > winnerOverlap)) {
winner = segment; winner = segment;
winnerOverlap = score; winnerPriority = priority;
} winnerOverlap = overlap;
} }
}
if (!winner) { if (!winner) {
buckets.push({ buckets.push({

View File

@@ -9,6 +9,7 @@ import {
import type { RecapTimelineResponse } from "@/lib/recap/types"; import type { RecapTimelineResponse } from "@/lib/recap/types";
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000; const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
const DEFAULT_RANGE_MS = 24 * 60 * 60 * 1000; const DEFAULT_RANGE_MS = 24 * 60 * 60 * 1000;
const MIN_RANGE_MS = 60 * 1000; const MIN_RANGE_MS = 60 * 1000;
const MAX_RANGE_MS = 72 * 60 * 60 * 1000; const MAX_RANGE_MS = 72 * 60 * 60 * 1000;
@@ -94,7 +95,10 @@ export async function getRecapTimelineForMachine(params: {
where: { where: {
orgId: params.orgId, orgId: params.orgId,
machineId: params.machineId, machineId: params.machineId,
ts: { gte: params.start, lte: params.end }, ts: {
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
lte: params.end,
},
}, },
orderBy: { ts: "asc" }, orderBy: { ts: "asc" },
select: { select: {
@@ -126,7 +130,10 @@ export async function getRecapTimelineForMachine(params: {
where: { where: {
orgId: params.orgId, orgId: params.orgId,
machineId: params.machineId, machineId: params.machineId,
ts: { gte: params.start, lte: params.end }, ts: {
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
lte: params.end,
},
}, },
}), }),
prisma.machineEvent.count({ prisma.machineEvent.count({