diff --git a/app/(app)/machines/MachinesClient.tsx b/app/(app)/machines/MachinesClient.tsx index 0e89e17..2576063 100644 --- a/app/(app)/machines/MachinesClient.tsx +++ b/app/(app)/machines/MachinesClient.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState, type KeyboardEvent } from "react"; import { useI18n } from "@/lib/i18n/useI18n"; +import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants"; type MachineRow = { id: string; @@ -20,6 +21,7 @@ type MachineRow = { }; }; const LIVE_REFRESH_MS = 5000; +const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS; function secondsAgo(ts: string | undefined, locale: string, fallback: string) { if (!ts) return fallback; @@ -31,7 +33,7 @@ function secondsAgo(ts: string | undefined, locale: string, fallback: string) { function isOffline(ts?: string) { if (!ts) return true; - return Date.now() - new Date(ts).getTime() > 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS) + return Date.now() - new Date(ts).getTime() > OFFLINE_MS; } function normalizeStatus(status?: string) { diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx index a8e8c09..405b25c 100644 --- a/app/(app)/machines/[machineId]/MachineDetailClient.tsx +++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx @@ -21,6 +21,7 @@ import { import { useI18n } from "@/lib/i18n/useI18n"; import { useScreenlessMode } from "@/lib/ui/screenlessMode"; import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types"; +import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants"; import { computeWidths, formatDuration, @@ -375,6 +376,7 @@ function getMinuteFlooredOneHourRange(referenceMs = Date.now()) { function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) { const [timeline, setTimeline] = useState(null); const [timelineLoading, setTimelineLoading] = useState(true); + const [showWindowInfo, setShowWindowInfo] = useState(false); const timelineHashRef = useRef(""); useEffect(() => { @@ -444,7 +446,13 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
{t("machine.detail.activity.title")}
{t("machine.detail.activity.subtitle")}
-
1h
+
@@ -500,6 +508,31 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli )}
+ + {showWindowInfo ? ( +
+
+

+ {t("machine.detail.activity.windowModalTitle")} +

+

{t("machine.detail.activity.windowModalBody")}

+
+ +
+
+
+ ) : null} ); } @@ -800,7 +833,7 @@ export default function MachineDetailClient() { function isOffline(ts?: string) { 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) { diff --git a/app/(app)/overview/OverviewClient.tsx b/app/(app)/overview/OverviewClient.tsx index 783e17b..c1ec490 100644 --- a/app/(app)/overview/OverviewClient.tsx +++ b/app/(app)/overview/OverviewClient.tsx @@ -3,9 +3,10 @@ import Link from "next/link"; import { Suspense, lazy, useEffect, useMemo, useState } from "react"; import { useI18n } from "@/lib/i18n/useI18n"; +import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants"; 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 OverviewTimeline = lazy(() => import("./OverviewTimeline")); @@ -199,20 +200,65 @@ export default function OverviewClient({ .map((m) => { const hb = m.latestHeartbeat; const offline = isOffline(heartbeatTime(hb)); + const status = normalizeStatus(hb?.status); const k = m.latestKpi; 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; - if (offline) score += 100; - if (oee != null && oee < 75) score += 50; - if (oee != null && oee < 85) score += 25; - return { machine: m, offline, oee, score }; + + // Trigger 1: offline (highest priority — can't tell what's wrong) + if (offline) { + 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) .sort((a, b) => b.score - a.score) .slice(0, 6); return list; - }, [machines]); + }, [machines, t]); return (
@@ -346,8 +392,12 @@ export default function OverviewClient({
{t("overview.noUrgent")}
) : (
- {attention.map(({ machine, offline, oee }) => ( -
+ {attention.map(({ machine, offline, oee, reasons }) => ( +
{machine.name}
@@ -359,7 +409,7 @@ export default function OverviewClient({ {secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
-
+
{offline ? t("overview.status.offline") : t("overview.status.online")} - {oee != null && ( + {oee != null && !offline && ( OEE {fmtPct(oee)} )}
-
+ {reasons.length > 0 && ( +
    + {reasons.map((r, i) => ( +
  • · {r}
  • + ))} +
+ )} + ))}
)} diff --git a/app/(app)/recap/[machineId]/RecapDetailClient.tsx b/app/(app)/recap/[machineId]/RecapDetailClient.tsx index 67558fc..bd2279d 100644 --- a/app/(app)/recap/[machineId]/RecapDetailClient.tsx +++ b/app/(app)/recap/[machineId]/RecapDetailClient.tsx @@ -219,6 +219,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) { hasData={timelineHasData} loading={timelineLoading} locale={locale} + rangeMode={initialData.range.mode} />
diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts index 8ce7b28..7a855dc 100644 --- a/app/api/reports/route.ts +++ b/app/api/reports/route.ts @@ -298,25 +298,68 @@ export async function GET(req: NextRequest) { 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(); + for (const k of kpiRows) { const t = k.ts.toISOString(); - if (!isProductionSnapshot(k.trackingEnabled, k.productionStarted)) { - // Preserve timeline gaps across non-production windows for OEE-family charting. + let b = tsBuckets.get(t); + 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.availability.push({ t, v: null }); trend.performance.push({ t, v: null }); trend.quality.push({ t, v: null }); } else { - trend.oee.push({ t, v: safeNum(k.oee) != null ? Number(k.oee) : null }); - trend.availability.push({ t, v: safeNum(k.availability) != null ? Number(k.availability) : null }); - trend.performance.push({ t, v: safeNum(k.performance) != null ? Number(k.performance) : null }); - trend.quality.push({ t, v: safeNum(k.quality) != null ? Number(k.quality) : null }); + trend.oee.push({ t, v: b.oeeCount ? b.oeeSum / b.oeeCount : null }); + trend.availability.push({ t, v: b.availCount ? b.availSum / b.availCount : null }); + trend.performance.push({ t, v: b.perfCount ? b.perfSum / b.perfCount : null }); + trend.quality.push({ t, v: b.qualCount ? b.qualSum / b.qualCount : null }); } - - const good = safeNum(k.good); - const scrap = safeNum(k.scrap); - if (good != null && scrap != null && good + scrap > 0) { - trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 }); + const total = b.goodSum + b.scrapSum; + if (total > 0) { + trend.scrapRate.push({ t, v: (b.scrapSum / total) * 100 }); } } const cycleRowsStart = nowMs(); diff --git a/components/recap/RecapFullTimeline.tsx b/components/recap/RecapFullTimeline.tsx index 5d1e326..1e0b795 100644 --- a/components/recap/RecapFullTimeline.tsx +++ b/components/recap/RecapFullTimeline.tsx @@ -1,6 +1,6 @@ "use client"; -import type { RecapTimelineSegment } from "@/lib/recap/types"; +import type { RecapRangeMode, RecapTimelineSegment } from "@/lib/recap/types"; import { computeWidths, formatDuration, @@ -19,6 +19,7 @@ type Props = { locale: string; hasData?: boolean; loading?: boolean; + rangeMode?: RecapRangeMode; }; export default function RecapFullTimeline({ @@ -28,6 +29,7 @@ export default function RecapFullTimeline({ locale, hasData = false, loading = false, + rangeMode, }: Props) { const { t } = useI18n(); const startMs = new Date(rangeStart).getTime(); @@ -36,10 +38,19 @@ export default function RecapFullTimeline({ 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 (
-
{t("recap.timeline.title")}
+
{titleText}
{loading ? (
diff --git a/components/recap/RecapMachineCard.tsx b/components/recap/RecapMachineCard.tsx index cc0f976..dbfbf60 100644 --- a/components/recap/RecapMachineCard.tsx +++ b/components/recap/RecapMachineCard.tsx @@ -59,7 +59,7 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop async function loadTimeline() { try { 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" } ); const json = await res.json().catch(() => null); diff --git a/components/recap/RecapMiniTimeline.tsx b/components/recap/RecapMiniTimeline.tsx index b9c211d..89e6868 100644 --- a/components/recap/RecapMiniTimeline.tsx +++ b/components/recap/RecapMiniTimeline.tsx @@ -19,7 +19,7 @@ type Props = { hasData?: boolean; }; -const MIN_SEGMENT_PCT = 1.5; +const MIN_SEGMENT_PCT = 0.5; export default function RecapMiniTimeline({ rangeStart, diff --git a/components/recap/RecapProductionBySku.tsx b/components/recap/RecapProductionBySku.tsx index b28e1a7..06cac16 100644 --- a/components/recap/RecapProductionBySku.tsx +++ b/components/recap/RecapProductionBySku.tsx @@ -1,7 +1,6 @@ "use client"; import { useI18n } from "@/lib/i18n/useI18n"; -import { formatRecapProgressPercent } from "@/lib/recap/progressDisplay"; import type { RecapSkuRow } from "@/lib/recap/types"; type Props = { @@ -9,7 +8,7 @@ type Props = { }; export default function RecapProductionBySku({ rows }: Props) { - const { t, locale } = useI18n(); + const { t } = useI18n(); return (
@@ -24,29 +23,21 @@ export default function RecapProductionBySku({ rows }: Props) { {t("recap.production.sku")} {t("recap.production.good")} - {t("recap.production.scrap")} - {t("recap.production.target")} - {t("recap.production.progress")} + {t("recap.production.scrap")} - {rows.slice(0, 10).map((row) => { - const progress = - row.progressPct == null ? "—" : formatRecapProgressPercent(row.progressPct, locale); - return ( - - {row.sku} - {row.good} - 0 ? "text-red-300" : ""}`}>{row.scrap} - {row.target ?? "--"} - {progress} - - ); - })} + {rows.slice(0, 10).map((row) => ( + + {row.sku} + {row.good} + 0 ? "text-red-300" : ""}`}>{row.scrap} + + ))}
)}
); -} +} \ No newline at end of file diff --git a/components/recap/RecapTimeline.tsx b/components/recap/RecapTimeline.tsx index b2b0234..1092417 100644 --- a/components/recap/RecapTimeline.tsx +++ b/components/recap/RecapTimeline.tsx @@ -17,7 +17,7 @@ const COLORS: Record = { "slow-cycle": "bg-amber-500 text-black", idle: "bg-zinc-600 text-zinc-300", }; -const MIN_SEGMENT_PCT = 1.5; +const MIN_SEGMENT_PCT = 0.3; const LABEL_MIN_PCT = 5; function fmtTime(valueMs: number, locale: string) { diff --git a/components/recap/timelineRender.ts b/components/recap/timelineRender.ts index 0decfd0..4103f2f 100644 --- a/components/recap/timelineRender.ts +++ b/components/recap/timelineRender.ts @@ -10,7 +10,7 @@ export const TIMELINE_COLORS: Record = { }; 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) { return new Date(valueMs).toLocaleTimeString(locale, { diff --git a/fix5.md b/fix5.md new file mode 100644 index 0000000..a0a96c7 --- /dev/null +++ b/fix5.md @@ -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 ~642–678 (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 365–373): +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
1h
+AFTER: +tsx
24h
+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 1–22): +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 24–42): +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 ( +
+
{t("recap.timeline.title")}
+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 ( +
+
{titleText}
+File B: app/(app)/recap/[machineId]/RecapDetailClient.tsx +BEFORE (around lines 215–222): +tsx +AFTER: +tsx +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. \ No newline at end of file diff --git a/fix6.md b/fix6.md new file mode 100644 index 0000000..d519ed7 --- /dev/null +++ b/fix6.md @@ -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 ~58–76 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. \ No newline at end of file diff --git a/flows (63).json b/flows (63).json index 8f71e34..eef9e65 100644 --- a/flows (63).json +++ b/flows (63).json @@ -2940,7 +2940,7 @@ "z": "8ccf34b55a2afcad", "g": "1d1ce0cb54c52345", "name": "Apply settings + update UI", - "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || payload.ok === false) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings fetch failed\" });\n return [null, null];\n}\n\nconst effective = payload.effectiveSettings || payload.settings || payload;\nif (!effective || typeof effective !== \"object\") {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings payload missing\" });\n return [null, null];\n}\n\nconst defaults = effective.defaults || {};\nconst shiftSchedule = effective.shiftSchedule || {};\nconst thresholds = effective.thresholds || {};\nconst incomingCatalog = effective.reasonCatalog || null;\n\nconst settings = global.get(\"settings\") || {};\n\nconst nextMoldTotal = Number(defaults.moldTotal ?? settings.moldTotal ?? 0);\nconst nextMoldActive = Number(defaults.moldActive ?? settings.moldActive ?? 0);\n\nsettings.moldTotal = nextMoldTotal;\nsettings.moldActive = nextMoldActive;\n\nif (Array.isArray(shiftSchedule.shifts) && shiftSchedule.shifts.length) {\n settings.shifts = shiftSchedule.shifts.map((s) => ({\n start: s.start,\n end: s.end\n }));\n} else if (!Array.isArray(settings.shifts)) {\n settings.shifts = [{ start: \"08:00\", end: \"16:00\" }];\n}\n\nsettings.shiftChangeCompensation = Number(\n shiftSchedule.shiftChangeCompensationMin ?? settings.shiftChangeCompensation ?? 10\n);\nsettings.lunchBreakMinutes = Number(\n shiftSchedule.lunchBreakMin ?? settings.lunchBreakMinutes ?? 30\n);\n\nsettings.thresholdMultiplier = Number(\n thresholds.stoppageMultiplier ?? settings.thresholdMultiplier ?? 1.5\n);\nsettings.macroStoppageMultiplier = Number(\n thresholds.macroStoppageMultiplier ?? settings.macroStoppageMultiplier ?? 5\n);\nsettings.oeeAlertThreshold = Number(\n thresholds.oeeAlertThresholdPct ?? settings.oeeAlertThreshold ?? 90\n);\n\nconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {\n if (!Array.isArray(list)) return [];\n return list\n .map((c, idx) => {\n const categoryId = String(c.id || c.categoryId || (\"cat_\" + idx));\n const categoryLabel = String(c.label || c.categoryLabel || (fallbackLabelPrefix + \" \" + (idx + 1)));\n const detailsRaw = Array.isArray(c.children) ? c.children : (Array.isArray(c.details) ? c.details : []);\n const details = detailsRaw.map((d, jdx) => ({\n id: String(d.id || d.detailId || (categoryId + \"_d\" + jdx)),\n label: String(d.label || d.detailLabel || (\"Detalle \" + (jdx + 1)))\n }));\n return {\n id: categoryId,\n label: categoryLabel,\n children: details\n };\n })\n .filter((c) => c.label && c.children.length > 0);\n};\n\nconst currentCatalog = settings.reasonCatalog || {};\nconst nextCatalogVersion =\n Number(\n (incomingCatalog && incomingCatalog.version) ??\n effective.reasonCatalogVersion ??\n currentCatalog.version ??\n 1\n ) || 1;\n\nconst hasIncomingCatalog = !!(incomingCatalog && (Array.isArray(incomingCatalog.downtime) || Array.isArray(incomingCatalog.scrap)));\nconst normalizedIncoming = hasIncomingCatalog ? {\n version: nextCatalogVersion,\n downtime: normalizeCatalogItems(incomingCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(incomingCatalog.scrap || [], \"Scrap\")\n} : null;\n\nconst fallbackCatalog = {\n version: Number(currentCatalog.version || nextCatalogVersion || 1),\n downtime: normalizeCatalogItems(currentCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(currentCatalog.scrap || [], \"Scrap\")\n};\n\nsettings.reasonCatalog = normalizedIncoming || fallbackCatalog;\nsettings.reasonCatalog.version = Number(settings.reasonCatalog.version || 1);\n\n\nif (effective.version !== undefined) {\n settings.version = Number(effective.version);\n}\n\nglobal.set(\"settings\", settings);\ntry {\n global.set(\"settings\", settings, \"file\");\n} catch (err) {\n // ignore if file store is not configured\n}\nglobal.set(\"moldActive\", settings.moldActive);\nglobal.set(\"moldTotal\", settings.moldTotal);\n\nconst config = global.get(\"config\") || {};\nif (effective.orgId && !config.orgId) {\n config.orgId = effective.orgId;\n global.set(\"config\", config);\n}\n\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Settings synced\" });\n\nconst uiConfigMsg = {\n topic: \"shiftConfigData\",\n payload: {\n shifts: settings.shifts || [],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n }\n};\n\n\nconst uiMoldMsg = {\n topic: \"moldPresetSelected\",\n payload: {\n total: settings.moldTotal || 0,\n active: settings.moldActive || 0\n }\n};\n\nconst readOnly = config.settingsReadOnly !== false;\nconst uiReadOnlyMsg = { topic: \"settingsReadOnly\", payload: readOnly };\nconst uiReasonCatalogMsg = {\n topic: \"reasonCatalogData\",\n payload: settings.reasonCatalog\n};\n\nnode.send([uiConfigMsg, null]);\nnode.send([uiMoldMsg, null]);\nnode.send([uiReadOnlyMsg, null]);\nnode.send([uiReasonCatalogMsg, null]);\n\nconst update = msg._settingsUpdate || {};\nconst orgId = update.orgId || config.orgId || effective.orgId;\nconst machineId = update.machineId || config.machineId;\nconst version = Number(effective.version ?? update.version ?? 0);\n\nif (!orgId || !machineId) {\n return [null, null];\n}\n\nconst prefix = String(config.mqttTopicPrefix || \"mis\").replace(/\\/+$/, \"\");\nconst ackTopic = prefix + \"/org/\" + orgId + \"/machines/\" + machineId + \"/settings/ack\";\n\nconst ackMsg = {\n topic: ackTopic,\n payload: JSON.stringify({\n type: \"settings_ack\",\n orgId,\n machineId,\n version,\n source: \"node-red\",\n ts: new Date().toISOString()\n })\n};\n\nreturn [null, ackMsg];\n", + "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || payload.ok === false) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings fetch failed\" });\n return [null, null];\n}\n\nconst effective = payload.effectiveSettings || payload.settings || payload;\nif (!effective || typeof effective !== \"object\") {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings payload missing\" });\n return [null, null];\n}\n\nconst defaults = effective.defaults || {};\nconst shiftSchedule = effective.shiftSchedule || {};\nconst thresholds = effective.thresholds || {};\nconst incomingCatalog = effective.reasonCatalog || null;\n\nconst settings = global.get(\"settings\") || {};\n\nconst nextMoldTotal = Number(defaults.moldTotal ?? settings.moldTotal ?? 0);\nconst nextMoldActive = Number(defaults.moldActive ?? settings.moldActive ?? 0);\n\nsettings.moldTotal = nextMoldTotal;\nsettings.moldActive = nextMoldActive;\n\nif (Array.isArray(shiftSchedule.shifts) && shiftSchedule.shifts.length) {\n settings.shifts = shiftSchedule.shifts.map((s) => ({\n start: s.start,\n end: s.end\n }));\n} else if (!Array.isArray(settings.shifts)) {\n settings.shifts = [{ start: \"08:00\", end: \"16:00\" }];\n}\n\nsettings.shiftChangeCompensation = Number(\n shiftSchedule.shiftChangeCompensationMin ?? settings.shiftChangeCompensation ?? 10\n);\nsettings.lunchBreakMinutes = Number(\n shiftSchedule.lunchBreakMin ?? settings.lunchBreakMinutes ?? 30\n);\n\nsettings.thresholdMultiplier = Number(\n thresholds.stoppageMultiplier ?? settings.thresholdMultiplier ?? 1.5\n);\nsettings.macroStoppageMultiplier = Number(\n thresholds.macroStoppageMultiplier ?? settings.macroStoppageMultiplier ?? 5\n);\nsettings.oeeAlertThreshold = Number(\n thresholds.oeeAlertThresholdPct ?? settings.oeeAlertThreshold ?? 90\n);\n\n// ============================================================\n// CATALOG SANITIZER\n// Defense against leaked markdown/spec text being stored as\n// catalog labels in Control Tower. Rejects entries whose label\n// looks like documentation/notes rather than a real reason.\n// Tune MAX_LABEL_LEN if your real labels are longer.\n// ============================================================\nconst MAX_LABEL_LEN = 40;\n\nconst isCleanLabel = (s) => {\n if (typeof s !== \"string\") return false;\n const t = s.trim();\n if (!t) return false;\n if (t.length > MAX_LABEL_LEN) return false; // sentence-length text\n if (/[\\r\\n\\t]/.test(t)) return false; // multi-line content\n if (/^[-*#>|`\\[\\]]/.test(t)) return false; // markdown leaders: - * # > | ` [ ]\n if (/\\*\\*|__|```|~~~|###/.test(t)) return false; // markdown bold/code/heading\n if (/[(\\[<{][^)\\]>}]*$/.test(t)) return false; // unbalanced opening bracket -> truncated\n if (/=/.test(t)) return false; // code-like assignment (e.g. type=event)\n return true;\n};\n\nconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {\n if (!Array.isArray(list)) return [];\n\n const dropped = [];\n\n const cleaned = list\n .map((c, idx) => {\n const categoryId = String(c.id || c.categoryId || (\"cat_\" + idx));\n const categoryLabel = String(\n c.label || c.categoryLabel || (fallbackLabelPrefix + \" \" + (idx + 1))\n ).trim();\n\n const detailsRaw = Array.isArray(c.children)\n ? c.children\n : (Array.isArray(c.details) ? c.details : []);\n\n const details = detailsRaw\n .map((d, jdx) => ({\n id: String(d.id || d.detailId || (categoryId + \"_d\" + jdx)),\n label: String(d.label || d.detailLabel || (\"Detalle \" + (jdx + 1))).trim()\n }))\n .filter((d) => {\n if (isCleanLabel(d.label)) return true;\n dropped.push(\"detail<\" + categoryLabel.slice(0, 20) + \">: \" + d.label.slice(0, 50));\n return false;\n });\n\n return { id: categoryId, label: categoryLabel, children: details };\n })\n .filter((c) => {\n if (!isCleanLabel(c.label)) {\n dropped.push(\"category: \" + c.label.slice(0, 50));\n return false;\n }\n if (c.children.length === 0) {\n dropped.push(\"empty: \" + c.label.slice(0, 50));\n return false;\n }\n return true;\n });\n\n if (dropped.length > 0) {\n node.warn(\n \"[CATALOG SANITIZER \" + fallbackLabelPrefix + \"] Dropped \" +\n dropped.length + \" polluted entries:\\n - \" +\n dropped.slice(0, 15).join(\"\\n - \") +\n (dropped.length > 15 ? \"\\n ... (+\" + (dropped.length - 15) + \" more)\" : \"\")\n );\n }\n\n return cleaned;\n};\n\nconst currentCatalog = settings.reasonCatalog || {};\nconst nextCatalogVersion =\n Number(\n (incomingCatalog && incomingCatalog.version) ??\n effective.reasonCatalogVersion ??\n currentCatalog.version ??\n 1\n ) || 1;\n\nconst hasIncomingCatalog = !!(incomingCatalog && (Array.isArray(incomingCatalog.downtime) || Array.isArray(incomingCatalog.scrap)));\nconst normalizedIncoming = hasIncomingCatalog ? {\n version: nextCatalogVersion,\n downtime: normalizeCatalogItems(incomingCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(incomingCatalog.scrap || [], \"Scrap\")\n} : null;\n\nconst fallbackCatalog = {\n version: Number(currentCatalog.version || nextCatalogVersion || 1),\n downtime: normalizeCatalogItems(currentCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(currentCatalog.scrap || [], \"Scrap\")\n};\n\nsettings.reasonCatalog = normalizedIncoming || fallbackCatalog;\nsettings.reasonCatalog.version = Number(settings.reasonCatalog.version || 1);\n\n\nif (effective.version !== undefined) {\n settings.version = Number(effective.version);\n}\n\nglobal.set(\"settings\", settings);\ntry {\n global.set(\"settings\", settings, \"file\");\n} catch (err) {\n // ignore if file store is not configured\n}\nglobal.set(\"moldActive\", settings.moldActive);\nglobal.set(\"moldTotal\", settings.moldTotal);\n\nconst config = global.get(\"config\") || {};\nif (effective.orgId && !config.orgId) {\n config.orgId = effective.orgId;\n global.set(\"config\", config);\n}\n\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Settings synced\" });\n\nconst uiConfigMsg = {\n topic: \"shiftConfigData\",\n payload: {\n shifts: settings.shifts || [],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n }\n};\n\n\nconst uiMoldMsg = {\n topic: \"moldPresetSelected\",\n payload: {\n total: settings.moldTotal || 0,\n active: settings.moldActive || 0\n }\n};\n\nconst readOnly = config.settingsReadOnly !== false;\nconst uiReadOnlyMsg = { topic: \"settingsReadOnly\", payload: readOnly };\nconst uiReasonCatalogMsg = {\n topic: \"reasonCatalogData\",\n payload: settings.reasonCatalog\n};\n\nnode.send([uiConfigMsg, null]);\nnode.send([uiMoldMsg, null]);\nnode.send([uiReadOnlyMsg, null]);\nnode.send([uiReasonCatalogMsg, null]);\n\nconst update = msg._settingsUpdate || {};\nconst orgId = update.orgId || config.orgId || effective.orgId;\nconst machineId = update.machineId || config.machineId;\nconst version = Number(effective.version ?? update.version ?? 0);\n\nif (!orgId || !machineId) {\n return [null, null];\n}\n\nconst prefix = String(config.mqttTopicPrefix || \"mis\").replace(/\\/+$/, \"\");\nconst ackTopic = prefix + \"/org/\" + orgId + \"/machines/\" + machineId + \"/settings/ack\";\n\nconst ackMsg = {\n topic: ackTopic,\n payload: JSON.stringify({\n type: \"settings_ack\",\n orgId,\n machineId,\n version,\n source: \"node-red\",\n ts: new Date().toISOString()\n })\n};\n\nreturn [null, ackMsg];\n", "outputs": 2, "timeout": 0, "noerr": 0, diff --git a/flows (64).json b/flows (64).json index f2ae287..82d633f 100644 --- a/flows (64).json +++ b/flows (64).json @@ -2943,7 +2943,7 @@ "z": "05d4cb231221b842", "g": "a1b43a9e095c10db", "name": "Apply settings + update UI", - "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || payload.ok === false) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings fetch failed\" });\n return [null, null];\n}\n\nconst effective = payload.effectiveSettings || payload.settings || payload;\nif (!effective || typeof effective !== \"object\") {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings payload missing\" });\n return [null, null];\n}\n\nconst defaults = effective.defaults || {};\nconst shiftSchedule = effective.shiftSchedule || {};\nconst thresholds = effective.thresholds || {};\nconst incomingCatalog = effective.reasonCatalog || null;\n\nconst settings = global.get(\"settings\") || {};\n\nconst nextMoldTotal = Number(defaults.moldTotal ?? settings.moldTotal ?? 0);\nconst nextMoldActive = Number(defaults.moldActive ?? settings.moldActive ?? 0);\n\nsettings.moldTotal = nextMoldTotal;\nsettings.moldActive = nextMoldActive;\n\nif (Array.isArray(shiftSchedule.shifts) && shiftSchedule.shifts.length) {\n settings.shifts = shiftSchedule.shifts.map((s) => ({\n start: s.start,\n end: s.end\n }));\n} else if (!Array.isArray(settings.shifts)) {\n settings.shifts = [{ start: \"08:00\", end: \"16:00\" }];\n}\n\nsettings.shiftChangeCompensation = Number(\n shiftSchedule.shiftChangeCompensationMin ?? settings.shiftChangeCompensation ?? 10\n);\nsettings.lunchBreakMinutes = Number(\n shiftSchedule.lunchBreakMin ?? settings.lunchBreakMinutes ?? 30\n);\n\nsettings.thresholdMultiplier = Number(\n thresholds.stoppageMultiplier ?? settings.thresholdMultiplier ?? 1.5\n);\nsettings.macroStoppageMultiplier = Number(\n thresholds.macroStoppageMultiplier ?? settings.macroStoppageMultiplier ?? 5\n);\nsettings.oeeAlertThreshold = Number(\n thresholds.oeeAlertThresholdPct ?? settings.oeeAlertThreshold ?? 90\n);\n\nconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {\n if (!Array.isArray(list)) return [];\n return list\n .map((c, idx) => {\n const categoryId = String(c.id || c.categoryId || (\"cat_\" + idx));\n const categoryLabel = String(c.label || c.categoryLabel || (fallbackLabelPrefix + \" \" + (idx + 1)));\n const detailsRaw = Array.isArray(c.children) ? c.children : (Array.isArray(c.details) ? c.details : []);\n const details = detailsRaw.map((d, jdx) => ({\n id: String(d.id || d.detailId || (categoryId + \"_d\" + jdx)),\n label: String(d.label || d.detailLabel || (\"Detalle \" + (jdx + 1)))\n }));\n return {\n id: categoryId,\n label: categoryLabel,\n children: details\n };\n })\n .filter((c) => c.label && c.children.length > 0);\n};\n\nconst currentCatalog = settings.reasonCatalog || {};\nconst nextCatalogVersion =\n Number(\n (incomingCatalog && incomingCatalog.version) ??\n effective.reasonCatalogVersion ??\n currentCatalog.version ??\n 1\n ) || 1;\n\nconst hasIncomingCatalog = !!(incomingCatalog && (Array.isArray(incomingCatalog.downtime) || Array.isArray(incomingCatalog.scrap)));\nconst normalizedIncoming = hasIncomingCatalog ? {\n version: nextCatalogVersion,\n downtime: normalizeCatalogItems(incomingCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(incomingCatalog.scrap || [], \"Scrap\")\n} : null;\n\nconst fallbackCatalog = {\n version: Number(currentCatalog.version || nextCatalogVersion || 1),\n downtime: normalizeCatalogItems(currentCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(currentCatalog.scrap || [], \"Scrap\")\n};\n\nsettings.reasonCatalog = normalizedIncoming || fallbackCatalog;\nsettings.reasonCatalog.version = Number(settings.reasonCatalog.version || 1);\n\n\nif (effective.version !== undefined) {\n settings.version = Number(effective.version);\n}\n\nglobal.set(\"settings\", settings);\ntry {\n global.set(\"settings\", settings, \"file\");\n} catch (err) {\n // ignore if file store is not configured\n}\nglobal.set(\"moldActive\", settings.moldActive);\nglobal.set(\"moldTotal\", settings.moldTotal);\n\nconst config = global.get(\"config\") || {};\nif (effective.orgId && !config.orgId) {\n config.orgId = effective.orgId;\n global.set(\"config\", config);\n}\n\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Settings synced\" });\n\nconst uiConfigMsg = {\n topic: \"shiftConfigData\",\n payload: {\n shifts: settings.shifts || [],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n }\n};\n\n\nconst uiMoldMsg = {\n topic: \"moldPresetSelected\",\n payload: {\n total: settings.moldTotal || 0,\n active: settings.moldActive || 0\n }\n};\n\nconst readOnly = config.settingsReadOnly !== false;\nconst uiReadOnlyMsg = { topic: \"settingsReadOnly\", payload: readOnly };\nconst uiReasonCatalogMsg = {\n topic: \"reasonCatalogData\",\n payload: settings.reasonCatalog\n};\n\nnode.send([uiConfigMsg, null]);\nnode.send([uiMoldMsg, null]);\nnode.send([uiReadOnlyMsg, null]);\nnode.send([uiReasonCatalogMsg, null]);\n\nconst update = msg._settingsUpdate || {};\nconst orgId = update.orgId || config.orgId || effective.orgId;\nconst machineId = update.machineId || config.machineId;\nconst version = Number(effective.version ?? update.version ?? 0);\n\nif (!orgId || !machineId) {\n return [null, null];\n}\n\nconst prefix = String(config.mqttTopicPrefix || \"mis\").replace(/\\/+$/, \"\");\nconst ackTopic = prefix + \"/org/\" + orgId + \"/machines/\" + machineId + \"/settings/ack\";\n\nconst ackMsg = {\n topic: ackTopic,\n payload: JSON.stringify({\n type: \"settings_ack\",\n orgId,\n machineId,\n version,\n source: \"node-red\",\n ts: new Date().toISOString()\n })\n};\n\nreturn [null, ackMsg];\n", + "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || payload.ok === false) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings fetch failed\" });\n return [null, null];\n}\n\nconst effective = payload.effectiveSettings || payload.settings || payload;\nif (!effective || typeof effective !== \"object\") {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings payload missing\" });\n return [null, null];\n}\n\nconst defaults = effective.defaults || {};\nconst shiftSchedule = effective.shiftSchedule || {};\nconst thresholds = effective.thresholds || {};\nconst incomingCatalog = effective.reasonCatalog || null;\n\nconst settings = global.get(\"settings\") || {};\n\nconst nextMoldTotal = Number(defaults.moldTotal ?? settings.moldTotal ?? 0);\nconst nextMoldActive = Number(defaults.moldActive ?? settings.moldActive ?? 0);\n\nsettings.moldTotal = nextMoldTotal;\nsettings.moldActive = nextMoldActive;\n\nif (Array.isArray(shiftSchedule.shifts) && shiftSchedule.shifts.length) {\n settings.shifts = shiftSchedule.shifts.map((s) => ({\n start: s.start,\n end: s.end\n }));\n} else if (!Array.isArray(settings.shifts)) {\n settings.shifts = [{ start: \"08:00\", end: \"16:00\" }];\n}\n\nsettings.shiftChangeCompensation = Number(\n shiftSchedule.shiftChangeCompensationMin ?? settings.shiftChangeCompensation ?? 10\n);\nsettings.lunchBreakMinutes = Number(\n shiftSchedule.lunchBreakMin ?? settings.lunchBreakMinutes ?? 30\n);\n\nsettings.thresholdMultiplier = Number(\n thresholds.stoppageMultiplier ?? settings.thresholdMultiplier ?? 1.5\n);\nsettings.macroStoppageMultiplier = Number(\n thresholds.macroStoppageMultiplier ?? settings.macroStoppageMultiplier ?? 5\n);\nsettings.oeeAlertThreshold = Number(\n thresholds.oeeAlertThresholdPct ?? settings.oeeAlertThreshold ?? 90\n);\n\n// ============================================================\n// CATALOG SANITIZER\n// Defense against leaked markdown/spec text being stored as\n// catalog labels in Control Tower. Rejects entries whose label\n// looks like documentation/notes rather than a real reason.\n// Tune MAX_LABEL_LEN if your real labels are longer.\n// ============================================================\nconst MAX_LABEL_LEN = 40;\n\nconst isCleanLabel = (s) => {\n if (typeof s !== \"string\") return false;\n const t = s.trim();\n if (!t) return false;\n if (t.length > MAX_LABEL_LEN) return false; // sentence-length text\n if (/[\\r\\n\\t]/.test(t)) return false; // multi-line content\n if (/^[-*#>|`\\[\\]]/.test(t)) return false; // markdown leaders: - * # > | ` [ ]\n if (/\\*\\*|__|```|~~~|###/.test(t)) return false; // markdown bold/code/heading\n if (/[(\\[<{][^)\\]>}]*$/.test(t)) return false; // unbalanced opening bracket -> truncated\n if (/=/.test(t)) return false; // code-like assignment (e.g. type=event)\n return true;\n};\n\nconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {\n if (!Array.isArray(list)) return [];\n\n const dropped = [];\n\n const cleaned = list\n .map((c, idx) => {\n const categoryId = String(c.id || c.categoryId || (\"cat_\" + idx));\n const categoryLabel = String(\n c.label || c.categoryLabel || (fallbackLabelPrefix + \" \" + (idx + 1))\n ).trim();\n\n const detailsRaw = Array.isArray(c.children)\n ? c.children\n : (Array.isArray(c.details) ? c.details : []);\n\n const details = detailsRaw\n .map((d, jdx) => ({\n id: String(d.id || d.detailId || (categoryId + \"_d\" + jdx)),\n label: String(d.label || d.detailLabel || (\"Detalle \" + (jdx + 1))).trim()\n }))\n .filter((d) => {\n if (isCleanLabel(d.label)) return true;\n dropped.push(\"detail<\" + categoryLabel.slice(0, 20) + \">: \" + d.label.slice(0, 50));\n return false;\n });\n\n return { id: categoryId, label: categoryLabel, children: details };\n })\n .filter((c) => {\n if (!isCleanLabel(c.label)) {\n dropped.push(\"category: \" + c.label.slice(0, 50));\n return false;\n }\n if (c.children.length === 0) {\n dropped.push(\"empty: \" + c.label.slice(0, 50));\n return false;\n }\n return true;\n });\n\n if (dropped.length > 0) {\n node.warn(\n \"[CATALOG SANITIZER \" + fallbackLabelPrefix + \"] Dropped \" +\n dropped.length + \" polluted entries:\\n - \" +\n dropped.slice(0, 15).join(\"\\n - \") +\n (dropped.length > 15 ? \"\\n ... (+\" + (dropped.length - 15) + \" more)\" : \"\")\n );\n }\n\n return cleaned;\n};\n\nconst currentCatalog = settings.reasonCatalog || {};\nconst nextCatalogVersion =\n Number(\n (incomingCatalog && incomingCatalog.version) ??\n effective.reasonCatalogVersion ??\n currentCatalog.version ??\n 1\n ) || 1;\n\nconst hasIncomingCatalog = !!(incomingCatalog && (Array.isArray(incomingCatalog.downtime) || Array.isArray(incomingCatalog.scrap)));\nconst normalizedIncoming = hasIncomingCatalog ? {\n version: nextCatalogVersion,\n downtime: normalizeCatalogItems(incomingCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(incomingCatalog.scrap || [], \"Scrap\")\n} : null;\n\nconst fallbackCatalog = {\n version: Number(currentCatalog.version || nextCatalogVersion || 1),\n downtime: normalizeCatalogItems(currentCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(currentCatalog.scrap || [], \"Scrap\")\n};\n\nsettings.reasonCatalog = normalizedIncoming || fallbackCatalog;\nsettings.reasonCatalog.version = Number(settings.reasonCatalog.version || 1);\n\n\nif (effective.version !== undefined) {\n settings.version = Number(effective.version);\n}\n\nglobal.set(\"settings\", settings);\ntry {\n global.set(\"settings\", settings, \"file\");\n} catch (err) {\n // ignore if file store is not configured\n}\nglobal.set(\"moldActive\", settings.moldActive);\nglobal.set(\"moldTotal\", settings.moldTotal);\n\nconst config = global.get(\"config\") || {};\nif (effective.orgId && !config.orgId) {\n config.orgId = effective.orgId;\n global.set(\"config\", config);\n}\n\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Settings synced\" });\n\nconst uiConfigMsg = {\n topic: \"shiftConfigData\",\n payload: {\n shifts: settings.shifts || [],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n }\n};\n\n\nconst uiMoldMsg = {\n topic: \"moldPresetSelected\",\n payload: {\n total: settings.moldTotal || 0,\n active: settings.moldActive || 0\n }\n};\n\nconst readOnly = config.settingsReadOnly !== false;\nconst uiReadOnlyMsg = { topic: \"settingsReadOnly\", payload: readOnly };\nconst uiReasonCatalogMsg = {\n topic: \"reasonCatalogData\",\n payload: settings.reasonCatalog\n};\n\nnode.send([uiConfigMsg, null]);\nnode.send([uiMoldMsg, null]);\nnode.send([uiReadOnlyMsg, null]);\nnode.send([uiReasonCatalogMsg, null]);\n\nconst update = msg._settingsUpdate || {};\nconst orgId = update.orgId || config.orgId || effective.orgId;\nconst machineId = update.machineId || config.machineId;\nconst version = Number(effective.version ?? update.version ?? 0);\n\nif (!orgId || !machineId) {\n return [null, null];\n}\n\nconst prefix = String(config.mqttTopicPrefix || \"mis\").replace(/\\/+$/, \"\");\nconst ackTopic = prefix + \"/org/\" + orgId + \"/machines/\" + machineId + \"/settings/ack\";\n\nconst ackMsg = {\n topic: ackTopic,\n payload: JSON.stringify({\n type: \"settings_ack\",\n orgId,\n machineId,\n version,\n source: \"node-red\",\n ts: new Date().toISOString()\n })\n};\n\nreturn [null, ackMsg];\n", "outputs": 2, "timeout": 0, "noerr": 0, @@ -4243,4 +4243,4 @@ "node-red-contrib-sm-16inpind": "1.0.1" } } -] \ No newline at end of file +] diff --git a/flows_file.json b/flows_file.json index 65663d4..eaeb2fe 100644 --- a/flows_file.json +++ b/flows_file.json @@ -3240,7 +3240,7 @@ "z": "a79ad45246b8dac2", "g": "6331afda01e9c79f", "name": "Apply settings + update UI", - "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || payload.ok === false) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings fetch failed\" });\n return [null, null];\n}\n\nconst effective = payload.effectiveSettings || payload.settings || payload;\nif (!effective || typeof effective !== \"object\") {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings payload missing\" });\n return [null, null];\n}\n\nconst defaults = effective.defaults || {};\nconst shiftSchedule = effective.shiftSchedule || {};\nconst thresholds = effective.thresholds || {};\nconst incomingCatalog = effective.reasonCatalog || null;\n\nconst settings = global.get(\"settings\") || {};\n\nconst nextMoldTotal = Number(defaults.moldTotal ?? settings.moldTotal ?? 0);\nconst nextMoldActive = Number(defaults.moldActive ?? settings.moldActive ?? 0);\n\nsettings.moldTotal = nextMoldTotal;\nsettings.moldActive = nextMoldActive;\n\nif (Array.isArray(shiftSchedule.shifts) && shiftSchedule.shifts.length) {\n settings.shifts = shiftSchedule.shifts.map((s) => ({\n start: s.start,\n end: s.end\n }));\n} else if (!Array.isArray(settings.shifts)) {\n settings.shifts = [{ start: \"08:00\", end: \"16:00\" }];\n}\n\nsettings.shiftChangeCompensation = Number(\n shiftSchedule.shiftChangeCompensationMin ?? settings.shiftChangeCompensation ?? 10\n);\nsettings.lunchBreakMinutes = Number(\n shiftSchedule.lunchBreakMin ?? settings.lunchBreakMinutes ?? 30\n);\n\nsettings.thresholdMultiplier = Number(\n thresholds.stoppageMultiplier ?? settings.thresholdMultiplier ?? 1.5\n);\nsettings.macroStoppageMultiplier = Number(\n thresholds.macroStoppageMultiplier ?? settings.macroStoppageMultiplier ?? 5\n);\nsettings.oeeAlertThreshold = Number(\n thresholds.oeeAlertThresholdPct ?? settings.oeeAlertThreshold ?? 90\n);\n\nconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {\n if (!Array.isArray(list)) return [];\n return list\n .map((c, idx) => {\n const categoryId = String(c.id || c.categoryId || (\"cat_\" + idx));\n const categoryLabel = String(c.label || c.categoryLabel || (fallbackLabelPrefix + \" \" + (idx + 1)));\n const detailsRaw = Array.isArray(c.children) ? c.children : (Array.isArray(c.details) ? c.details : []);\n const details = detailsRaw.map((d, jdx) => ({\n id: String(d.id || d.detailId || (categoryId + \"_d\" + jdx)),\n label: String(d.label || d.detailLabel || (\"Detalle \" + (jdx + 1)))\n }));\n return {\n id: categoryId,\n label: categoryLabel,\n children: details\n };\n })\n .filter((c) => c.label && c.children.length > 0);\n};\n\nconst currentCatalog = settings.reasonCatalog || {};\nconst nextCatalogVersion =\n Number(\n (incomingCatalog && incomingCatalog.version) ??\n effective.reasonCatalogVersion ??\n currentCatalog.version ??\n 1\n ) || 1;\n\nconst hasIncomingCatalog = !!(incomingCatalog && (Array.isArray(incomingCatalog.downtime) || Array.isArray(incomingCatalog.scrap)));\nconst normalizedIncoming = hasIncomingCatalog ? {\n version: nextCatalogVersion,\n downtime: normalizeCatalogItems(incomingCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(incomingCatalog.scrap || [], \"Scrap\")\n} : null;\n\nconst fallbackCatalog = {\n version: Number(currentCatalog.version || nextCatalogVersion || 1),\n downtime: normalizeCatalogItems(currentCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(currentCatalog.scrap || [], \"Scrap\")\n};\n\nsettings.reasonCatalog = normalizedIncoming || fallbackCatalog;\nsettings.reasonCatalog.version = Number(settings.reasonCatalog.version || 1);\n\n\nif (effective.version !== undefined) {\n settings.version = Number(effective.version);\n}\n\nglobal.set(\"settings\", settings);\ntry {\n global.set(\"settings\", settings, \"file\");\n} catch (err) {\n // ignore if file store is not configured\n}\nglobal.set(\"moldActive\", settings.moldActive);\nglobal.set(\"moldTotal\", settings.moldTotal);\n\nconst config = global.get(\"config\") || {};\nif (effective.orgId && !config.orgId) {\n config.orgId = effective.orgId;\n global.set(\"config\", config);\n}\n\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Settings synced\" });\n\nconst uiConfigMsg = {\n topic: \"shiftConfigData\",\n payload: {\n shifts: settings.shifts || [],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n }\n};\n\n\nconst uiMoldMsg = {\n topic: \"moldPresetSelected\",\n payload: {\n total: settings.moldTotal || 0,\n active: settings.moldActive || 0\n }\n};\n\nconst readOnly = config.settingsReadOnly !== false;\nconst uiReadOnlyMsg = { topic: \"settingsReadOnly\", payload: readOnly };\nconst uiReasonCatalogMsg = {\n topic: \"reasonCatalogData\",\n payload: settings.reasonCatalog\n};\n\nnode.send([uiConfigMsg, null]);\nnode.send([uiMoldMsg, null]);\nnode.send([uiReadOnlyMsg, null]);\nnode.send([uiReasonCatalogMsg, null]);\n\nconst update = msg._settingsUpdate || {};\nconst orgId = update.orgId || config.orgId || effective.orgId;\nconst machineId = update.machineId || config.machineId;\nconst version = Number(effective.version ?? update.version ?? 0);\n\nif (!orgId || !machineId) {\n return [null, null];\n}\n\nconst prefix = String(config.mqttTopicPrefix || \"mis\").replace(/\\/+$/, \"\");\nconst ackTopic = prefix + \"/org/\" + orgId + \"/machines/\" + machineId + \"/settings/ack\";\n\nconst ackMsg = {\n topic: ackTopic,\n payload: JSON.stringify({\n type: \"settings_ack\",\n orgId,\n machineId,\n version,\n source: \"node-red\",\n ts: new Date().toISOString()\n })\n};\n\nreturn [null, ackMsg];\n", + "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || payload.ok === false) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings fetch failed\" });\n return [null, null];\n}\n\nconst effective = payload.effectiveSettings || payload.settings || payload;\nif (!effective || typeof effective !== \"object\") {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings payload missing\" });\n return [null, null];\n}\n\nconst defaults = effective.defaults || {};\nconst shiftSchedule = effective.shiftSchedule || {};\nconst thresholds = effective.thresholds || {};\nconst incomingCatalog = effective.reasonCatalog || null;\n\nconst settings = global.get(\"settings\") || {};\n\nconst nextMoldTotal = Number(defaults.moldTotal ?? settings.moldTotal ?? 0);\nconst nextMoldActive = Number(defaults.moldActive ?? settings.moldActive ?? 0);\n\nsettings.moldTotal = nextMoldTotal;\nsettings.moldActive = nextMoldActive;\n\nif (Array.isArray(shiftSchedule.shifts) && shiftSchedule.shifts.length) {\n settings.shifts = shiftSchedule.shifts.map((s) => ({\n start: s.start,\n end: s.end\n }));\n} else if (!Array.isArray(settings.shifts)) {\n settings.shifts = [{ start: \"08:00\", end: \"16:00\" }];\n}\n\nsettings.shiftChangeCompensation = Number(\n shiftSchedule.shiftChangeCompensationMin ?? settings.shiftChangeCompensation ?? 10\n);\nsettings.lunchBreakMinutes = Number(\n shiftSchedule.lunchBreakMin ?? settings.lunchBreakMinutes ?? 30\n);\n\nsettings.thresholdMultiplier = Number(\n thresholds.stoppageMultiplier ?? settings.thresholdMultiplier ?? 1.5\n);\nsettings.macroStoppageMultiplier = Number(\n thresholds.macroStoppageMultiplier ?? settings.macroStoppageMultiplier ?? 5\n);\nsettings.oeeAlertThreshold = Number(\n thresholds.oeeAlertThresholdPct ?? settings.oeeAlertThreshold ?? 90\n);\n\n// ============================================================\n// CATALOG SANITIZER\n// Defense against leaked markdown/spec text being stored as\n// catalog labels in Control Tower. Rejects entries whose label\n// looks like documentation/notes rather than a real reason.\n// Tune MAX_LABEL_LEN if your real labels are longer.\n// ============================================================\nconst MAX_LABEL_LEN = 40;\n\nconst isCleanLabel = (s) => {\n if (typeof s !== \"string\") return false;\n const t = s.trim();\n if (!t) return false;\n if (t.length > MAX_LABEL_LEN) return false; // sentence-length text\n if (/[\\r\\n\\t]/.test(t)) return false; // multi-line content\n if (/^[-*#>|`\\[\\]]/.test(t)) return false; // markdown leaders: - * # > | ` [ ]\n if (/\\*\\*|__|```|~~~|###/.test(t)) return false; // markdown bold/code/heading\n if (/[(\\[<{][^)\\]>}]*$/.test(t)) return false; // unbalanced opening bracket -> truncated\n if (/=/.test(t)) return false; // code-like assignment (e.g. type=event)\n return true;\n};\n\nconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {\n if (!Array.isArray(list)) return [];\n\n const dropped = [];\n\n const cleaned = list\n .map((c, idx) => {\n const categoryId = String(c.id || c.categoryId || (\"cat_\" + idx));\n const categoryLabel = String(\n c.label || c.categoryLabel || (fallbackLabelPrefix + \" \" + (idx + 1))\n ).trim();\n\n const detailsRaw = Array.isArray(c.children)\n ? c.children\n : (Array.isArray(c.details) ? c.details : []);\n\n const details = detailsRaw\n .map((d, jdx) => ({\n id: String(d.id || d.detailId || (categoryId + \"_d\" + jdx)),\n label: String(d.label || d.detailLabel || (\"Detalle \" + (jdx + 1))).trim()\n }))\n .filter((d) => {\n if (isCleanLabel(d.label)) return true;\n dropped.push(\"detail<\" + categoryLabel.slice(0, 20) + \">: \" + d.label.slice(0, 50));\n return false;\n });\n\n return { id: categoryId, label: categoryLabel, children: details };\n })\n .filter((c) => {\n if (!isCleanLabel(c.label)) {\n dropped.push(\"category: \" + c.label.slice(0, 50));\n return false;\n }\n if (c.children.length === 0) {\n dropped.push(\"empty: \" + c.label.slice(0, 50));\n return false;\n }\n return true;\n });\n\n if (dropped.length > 0) {\n node.warn(\n \"[CATALOG SANITIZER \" + fallbackLabelPrefix + \"] Dropped \" +\n dropped.length + \" polluted entries:\\n - \" +\n dropped.slice(0, 15).join(\"\\n - \") +\n (dropped.length > 15 ? \"\\n ... (+\" + (dropped.length - 15) + \" more)\" : \"\")\n );\n }\n\n return cleaned;\n};\n\nconst currentCatalog = settings.reasonCatalog || {};\nconst nextCatalogVersion =\n Number(\n (incomingCatalog && incomingCatalog.version) ??\n effective.reasonCatalogVersion ??\n currentCatalog.version ??\n 1\n ) || 1;\n\nconst hasIncomingCatalog = !!(incomingCatalog && (Array.isArray(incomingCatalog.downtime) || Array.isArray(incomingCatalog.scrap)));\nconst normalizedIncoming = hasIncomingCatalog ? {\n version: nextCatalogVersion,\n downtime: normalizeCatalogItems(incomingCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(incomingCatalog.scrap || [], \"Scrap\")\n} : null;\n\nconst fallbackCatalog = {\n version: Number(currentCatalog.version || nextCatalogVersion || 1),\n downtime: normalizeCatalogItems(currentCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(currentCatalog.scrap || [], \"Scrap\")\n};\n\nsettings.reasonCatalog = normalizedIncoming || fallbackCatalog;\nsettings.reasonCatalog.version = Number(settings.reasonCatalog.version || 1);\n\n\nif (effective.version !== undefined) {\n settings.version = Number(effective.version);\n}\n\nglobal.set(\"settings\", settings);\ntry {\n global.set(\"settings\", settings, \"file\");\n} catch (err) {\n // ignore if file store is not configured\n}\nglobal.set(\"moldActive\", settings.moldActive);\nglobal.set(\"moldTotal\", settings.moldTotal);\n\nconst config = global.get(\"config\") || {};\nif (effective.orgId && !config.orgId) {\n config.orgId = effective.orgId;\n global.set(\"config\", config);\n}\n\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Settings synced\" });\n\nconst uiConfigMsg = {\n topic: \"shiftConfigData\",\n payload: {\n shifts: settings.shifts || [],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n }\n};\n\n\nconst uiMoldMsg = {\n topic: \"moldPresetSelected\",\n payload: {\n total: settings.moldTotal || 0,\n active: settings.moldActive || 0\n }\n};\n\nconst readOnly = config.settingsReadOnly !== false;\nconst uiReadOnlyMsg = { topic: \"settingsReadOnly\", payload: readOnly };\nconst uiReasonCatalogMsg = {\n topic: \"reasonCatalogData\",\n payload: settings.reasonCatalog\n};\n\nnode.send([uiConfigMsg, null]);\nnode.send([uiMoldMsg, null]);\nnode.send([uiReadOnlyMsg, null]);\nnode.send([uiReasonCatalogMsg, null]);\n\nconst update = msg._settingsUpdate || {};\nconst orgId = update.orgId || config.orgId || effective.orgId;\nconst machineId = update.machineId || config.machineId;\nconst version = Number(effective.version ?? update.version ?? 0);\n\nif (!orgId || !machineId) {\n return [null, null];\n}\n\nconst prefix = String(config.mqttTopicPrefix || \"mis\").replace(/\\/+$/, \"\");\nconst ackTopic = prefix + \"/org/\" + orgId + \"/machines/\" + machineId + \"/settings/ack\";\n\nconst ackMsg = {\n topic: ackTopic,\n payload: JSON.stringify({\n type: \"settings_ack\",\n orgId,\n machineId,\n version,\n source: \"node-red\",\n ts: new Date().toISOString()\n })\n};\n\nreturn [null, ackMsg];\n", "outputs": 2, "timeout": 0, "noerr": 0, @@ -4424,4 +4424,4 @@ "y": 220, "wires": [] } -] \ No newline at end of file +] diff --git a/lib/i18n/en.json b/lib/i18n/en.json index 2cc24f6..aec10de 100644 --- a/lib/i18n/en.json +++ b/lib/i18n/en.json @@ -183,7 +183,7 @@ "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.title": "Timeline", "recap.timeline.noData": "No timeline data", "recap.timeline.type.production": "Production", "recap.timeline.type.moldChange": "Mold change", @@ -255,6 +255,9 @@ "machine.detail.bucket.unknown": "Unknown", "machine.detail.activity.title": "Machine Activity Timeline", "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.tooltip.cycle": "Cycle: {label}", "machine.detail.tooltip.duration": "Duration", @@ -621,5 +624,12 @@ "settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.", "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.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}%" } diff --git a/lib/i18n/es-MX.json b/lib/i18n/es-MX.json index 695f5da..c6b8618 100644 --- a/lib/i18n/es-MX.json +++ b/lib/i18n/es-MX.json @@ -104,6 +104,13 @@ "overview.event.macrostop": "macroparo", "overview.event.microstop": "microparo", "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.online": "EN LÍNEA", "overview.recap.title": "Resumen diario de turno", @@ -183,7 +190,7 @@ "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.title": "Timeline", "recap.timeline.noData": "Sin datos de línea de tiempo", "recap.timeline.type.production": "Producción", "recap.timeline.type.moldChange": "Cambio de molde", @@ -255,6 +262,9 @@ "machine.detail.bucket.unknown": "Desconocido", "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.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.tooltip.cycle": "Ciclo: {label}", "machine.detail.tooltip.duration": "Duración", diff --git a/lib/recap/getRecapData.ts b/lib/recap/getRecapData.ts index c148154..dc272cd 100644 --- a/lib/recap/getRecapData.ts +++ b/lib/recap/getRecapData.ts @@ -230,45 +230,6 @@ function moldStartMs(data: unknown, fallbackTs: Date) { 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: { machineId?: string | null; start?: string | null; @@ -306,7 +267,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, workOrderCounterRowsRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] = + const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] = await Promise.all([ prisma.orgSettings.findUnique({ where: { orgId: params.orgId }, @@ -401,10 +362,6 @@ async function computeRecap(params: Required> & { updatedAt: true, }, }), - loadWorkOrderCounterRows({ - orgId: params.orgId, - machineIds, - }), prisma.machineHeartbeat.findMany({ where: { orgId: params.orgId, @@ -473,7 +430,6 @@ 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(); @@ -508,12 +464,6 @@ 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); @@ -532,7 +482,6 @@ 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) ?? []; @@ -599,7 +548,7 @@ async function computeRecap(params: Required> & { const openWorkOrders = machineWorkOrdersSorted.filter( (wo) => String(wo.status).toUpperCase() !== "COMPLETED" ); - const authoritativeWorkOrderProgress = new Map< + const rangeWorkOrderProgress = new Map< string, { goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null } >(); @@ -639,58 +588,45 @@ async function computeRecap(params: Required> & { return created; }; - 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); - } - - 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, + 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)); + const woKey = workOrderKey(cycle.workOrderId); + authoritativeCycleCount += 1; + if (g === 0 && s === 0) continue; + goodParts += g; + scrapParts += s; + if (woKey) { + const progress = rangeWorkOrderProgress.get(woKey) ?? { + goodParts: 0, + scrapParts: 0, + cycleCount: 0, + firstTs: null, + lastTs: null, }; - }) + 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); const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime()); @@ -762,7 +698,7 @@ 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 = authoritativeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? { + const progress = rangeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? { goodParts: 0, scrapParts: 0, cycleCount: 0, @@ -801,19 +737,15 @@ async function computeRecap(params: Required> & { let activeProgressPct: number | null = null; let activeStartedAt: string | null = null; if (activeWorkOrderId) { - const authoritativeProgress = activeWorkOrderKey - ? authoritativeWorkOrderProgress.get(activeWorkOrderKey) ?? null - : null; - const producedForProgress = authoritativeProgress - ? authoritativeProgress.goodParts + authoritativeProgress.scrapParts + const rangeProgress = activeWorkOrderKey ? rangeWorkOrderProgress.get(activeWorkOrderKey) ?? null : null; + const producedForProgress = rangeProgress + ? rangeProgress.goodParts + rangeProgress.scrapParts : 0; const targetQty = safeNum(activeTargetSource?.targetQty); if (targetQty && targetQty > 0) { activeProgressPct = round2((producedForProgress / targetQty) * 100); } - activeStartedAt = toIso( - authoritativeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null - ); + activeStartedAt = toIso(rangeProgress?.firstTs ?? latestTelemetry?.ts ?? null); } const firstProductionMsAfterMoldStart = (startMs: number) => { diff --git a/lib/recap/recapUiConstants.ts b/lib/recap/recapUiConstants.ts index 3e390fe..a9ab1a8 100644 --- a/lib/recap/recapUiConstants.ts +++ b/lib/recap/recapUiConstants.ts @@ -1,4 +1,4 @@ /** * 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; diff --git a/lib/recap/redesign.ts b/lib/recap/redesign.ts index 08f3b86..a97fc6c 100644 --- a/lib/recap/redesign.ts +++ b/lib/recap/redesign.ts @@ -28,6 +28,7 @@ type DetailRangeInput = { const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS; const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000; +const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000; const RECAP_CACHE_TTL_SEC = 60; const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; const WEEKDAY_KEY_MAP: Record = { @@ -213,7 +214,10 @@ async function loadTimelineRowsForMachines(params: { where: { orgId: params.orgId, 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" }], select: { @@ -338,7 +342,7 @@ async function computeRecapSummary(params: { orgId: string; hours: number }) { segments, rangeStart: start, rangeEnd: end, - maxSegments: 30, + maxSegments: 60, }); return toSummaryMachine({ @@ -443,21 +447,25 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) { minutes: startMin % 60, timeZone, }); - const end = zonedToUtcDate({ + const shiftEndUtc = zonedToUtcDate({ ...endDate, hours: Math.floor(endMin / 60), minutes: endMin % 60, 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 { hasEnabledShifts: true, - range: { - start, - end, - }, + range: { start, end }, }; } diff --git a/lib/recap/timeline.ts b/lib/recap/timeline.ts index 2df6cbc..7937fc8 100644 --- a/lib/recap/timeline.ts +++ b/lib/recap/timeline.ts @@ -751,22 +751,24 @@ export function compressTimelineSegments(input: { const bucketEnd = i === maxSegments - 1 ? rangeEndMs : Math.trunc(rangeStartMs + (i + 1) * bucketMs); if (bucketEnd <= bucketStart) continue; - let winner: RecapTimelineSegment | null = null; - let winnerOverlap = -1; + let winner: RecapTimelineSegment | null = null; + let winnerPriority = -1; + 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; + 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; - } - } + const overlap = overlapEnd - overlapStart; + const priority = segmentPriority(segment.type); + + if (priority > winnerPriority || (priority === winnerPriority && overlap > winnerOverlap)) { + winner = segment; + winnerPriority = priority; + winnerOverlap = overlap; + } + } if (!winner) { buckets.push({ diff --git a/lib/recap/timelineApi.ts b/lib/recap/timelineApi.ts index 8c58f37..6313877 100644 --- a/lib/recap/timelineApi.ts +++ b/lib/recap/timelineApi.ts @@ -9,6 +9,7 @@ import { import type { RecapTimelineResponse } from "@/lib/recap/types"; 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 MIN_RANGE_MS = 60 * 1000; const MAX_RANGE_MS = 72 * 60 * 60 * 1000; @@ -94,7 +95,10 @@ export async function getRecapTimelineForMachine(params: { where: { orgId: params.orgId, 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" }, select: { @@ -126,7 +130,10 @@ export async function getRecapTimelineForMachine(params: { where: { orgId: params.orgId, 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({