From 30513ff73dfa64ebcee61a01953431011c4ef091 Mon Sep 17 00:00:00 2001 From: Marcelo Date: Fri, 24 Apr 2026 15:17:28 +0000 Subject: [PATCH] changes --- .../[machineId]/MachineDetailClient.tsx | 253 +++++++++--------- .../recap/[machineId]/RecapDetailClient.tsx | 24 +- components/recap/RecapBanners.tsx | 6 +- components/recap/RecapKpiRow.tsx | 14 +- fix2.md | 42 +++ lib/i18n/en.json | 7 + lib/i18n/es-MX.json | 7 + lib/recap/redesign.ts | 136 ++++++++-- lib/recap/types.ts | 3 + 9 files changed, 337 insertions(+), 155 deletions(-) create mode 100644 fix2.md diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx index a805267..76cb4e9 100644 --- a/app/(app)/machines/[machineId]/MachineDetailClient.tsx +++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx @@ -302,6 +302,133 @@ function toErrorMessage(value: unknown, fallback: string): string { return fallback; } +type MachineActivityTimelineProps = { + machineId?: string; + locale: string; + t: (key: string, vars?: Record) => string; +}; + +function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) { + const [timeline, setTimeline] = useState(null); + const [timelineLoading, setTimelineLoading] = useState(true); + const timelineHashRef = useRef(""); + + useEffect(() => { + if (!machineId) return; + let alive = true; + timelineHashRef.current = ""; + setTimelineLoading(true); + + async function loadTimeline() { + try { + const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" }); + const json = await res.json().catch(() => null); + if (!alive || !res.ok || !json) return; + const nextTimeline = json as RecapTimelineResponse; + const nextHash = JSON.stringify({ + hasData: nextTimeline.hasData, + segments: nextTimeline.segments.map((segment) => ({ + type: segment.type, + startMs: segment.startMs, + endMs: segment.endMs, + })), + }); + if (timelineHashRef.current === nextHash) return; + timelineHashRef.current = nextHash; + setTimeline(nextTimeline); + } finally { + if (alive) setTimelineLoading(false); + } + } + + void loadTimeline(); + const timer = window.setInterval(() => { + void loadTimeline(); + }, 30000); + + return () => { + alive = false; + window.clearInterval(timer); + }; + }, [machineId]); + + const hasData = timeline?.hasData ?? false; + const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000; + const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now(); + const totalMs = Math.max(1, endMs - startMs); + const normalized = useMemo(() => { + if (!timeline || !hasData) return [] as RecapTimelineSegment[]; + return normalizeTimelineSegments(timeline.segments, startMs, endMs); + }, [timeline, hasData, startMs, endMs]); + const widths = useMemo(() => computeWidths(normalized, totalMs, 1.5), [normalized, totalMs]); + + return ( +
+
+
+
{t("machine.detail.activity.title")}
+
{t("machine.detail.activity.subtitle")}
+
+
1h
+
+ +
+ {(["production", "mold-change", "macrostop", "microstop", "idle"] as const).map((type) => ( +
+ + + {type === "production" ? t("recap.timeline.type.production") : null} + {type === "mold-change" ? t("recap.timeline.type.moldChange") : null} + {type === "macrostop" ? t("recap.timeline.type.macrostop") : null} + {type === "microstop" ? t("recap.timeline.type.microstop") : null} + {type === "idle" ? t("recap.timeline.type.idle") : null} + +
+ ))} +
+ +
+
+ {timelineLoading ? t("common.loading") : formatTime(startMs, locale)} + {formatTime(endMs, locale)} +
+ +
+ {!hasData ? ( +
+ {t("machine.detail.activity.noData")} +
+ ) : ( + normalized.map((segment, idx) => { + const widthPct = widths[idx] ?? 0; + const typeLabel = + segment.type === "production" + ? t("recap.timeline.type.production") + : segment.type === "mold-change" + ? t("recap.timeline.type.moldChange") + : segment.type === "macrostop" + ? t("recap.timeline.type.macrostop") + : segment.type === "microstop" || segment.type === "slow-cycle" + ? t("recap.timeline.type.microstop") + : t("recap.timeline.type.idle"); + const title = `${typeLabel} · ${formatDuration(segment.startMs, segment.endMs)}`; + + return ( +
+ ); + }) + )} +
+
+
+ ); +} + export default function MachineDetailClient() { const { t, locale } = useI18n(); const { screenlessMode } = useScreenlessMode(); @@ -683,130 +810,6 @@ export default function MachineDetailClient() { ); } - function MachineActivityTimeline({ machineId }: { machineId: string | undefined }) { - const [timeline, setTimeline] = useState(null); - const [timelineLoading, setTimelineLoading] = useState(true); - const timelineHashRef = useRef(""); - - useEffect(() => { - if (!machineId) return; - let alive = true; - timelineHashRef.current = ""; - setTimeline(null); - setTimelineLoading(true); - - async function loadTimeline() { - try { - const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" }); - const json = await res.json().catch(() => null); - if (!alive || !res.ok || !json) return; - const nextTimeline = json as RecapTimelineResponse; - const nextHash = JSON.stringify({ - start: nextTimeline.range?.start ?? "", - end: nextTimeline.range?.end ?? "", - hasData: nextTimeline.hasData, - segments: nextTimeline.segments.map((segment) => ({ - type: segment.type, - startMs: segment.startMs, - endMs: segment.endMs, - })), - }); - if (timelineHashRef.current === nextHash) return; - timelineHashRef.current = nextHash; - setTimeline(nextTimeline); - } finally { - if (alive) setTimelineLoading(false); - } - } - - void loadTimeline(); - const timer = window.setInterval(() => { - void loadTimeline(); - }, 30000); - - return () => { - alive = false; - window.clearInterval(timer); - }; - }, [machineId]); - - const hasData = timeline?.hasData ?? false; - const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000; - const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now(); - const totalMs = Math.max(1, endMs - startMs); - const normalized = useMemo(() => { - if (!timeline || !hasData) return [] as RecapTimelineSegment[]; - return normalizeTimelineSegments(timeline.segments, startMs, endMs); - }, [timeline, hasData, startMs, endMs]); - const widths = useMemo(() => computeWidths(normalized, totalMs, 1.5), [normalized, totalMs]); - - return ( -
-
-
-
{t("machine.detail.activity.title")}
-
{t("machine.detail.activity.subtitle")}
-
-
1h
-
- -
- {(["production", "mold-change", "macrostop", "microstop", "idle"] as const).map((type) => ( -
- - - {type === "production" ? t("recap.timeline.type.production") : null} - {type === "mold-change" ? t("recap.timeline.type.moldChange") : null} - {type === "macrostop" ? t("recap.timeline.type.macrostop") : null} - {type === "microstop" ? t("recap.timeline.type.microstop") : null} - {type === "idle" ? t("recap.timeline.type.idle") : null} - -
- ))} -
- -
-
- {timelineLoading ? t("common.loading") : formatTime(startMs, locale)} - {formatTime(endMs, locale)} -
- -
- {!hasData ? ( -
- {t("machine.detail.activity.noData")} -
- ) : ( - normalized.map((segment, idx) => { - const widthPct = widths[idx] ?? 0; - const typeLabel = - segment.type === "production" - ? t("recap.timeline.type.production") - : segment.type === "mold-change" - ? t("recap.timeline.type.moldChange") - : segment.type === "macrostop" - ? t("recap.timeline.type.macrostop") - : segment.type === "microstop" || segment.type === "slow-cycle" - ? t("recap.timeline.type.microstop") - : t("recap.timeline.type.idle"); - const title = `${typeLabel} · ${formatDuration(segment.startMs, segment.endMs)}`; - - return ( -
- ); - }) - )} -
-
-
- ); - } - function Modal({ open, onClose, @@ -1115,7 +1118,7 @@ export default function MachineDetailClient() {
- +
{!screenlessMode && (
diff --git a/app/(app)/recap/[machineId]/RecapDetailClient.tsx b/app/(app)/recap/[machineId]/RecapDetailClient.tsx index 8c61968..281df71 100644 --- a/app/(app)/recap/[machineId]/RecapDetailClient.tsx +++ b/app/(app)/recap/[machineId]/RecapDetailClient.tsx @@ -42,7 +42,12 @@ export default function RecapDetailClient({ machineId, initialData }: Props) { const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start)); const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end)); - const selectedRange = (searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.mode; + const requestedRange = + (searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.requestedMode ?? initialData.range.mode; + const selectedRange = requestedRange; + const shiftAvailable = initialData.range.shiftAvailable ?? true; + const shiftFallbackReason = initialData.range.fallbackReason; + const shiftFallbackActive = selectedRange === "shift" && initialData.range.mode !== "shift"; function pushRange(nextRange: RecapRangeMode, start?: string, end?: string) { const params = new URLSearchParams(searchParams.toString()); @@ -123,7 +128,9 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
+ {!shiftAvailable ? ( +
+ {t("recap.range.shiftUnavailable")} +
+ ) : null} + + {shiftFallbackActive ? ( +
+ {shiftFallbackReason === "shift-inactive" ? t("recap.range.shiftFallbackInactive") : t("recap.range.shiftFallbackUnavailable")} +
+ ) : null} + {selectedRange === "custom" ? (
diff --git a/components/recap/RecapBanners.tsx b/components/recap/RecapBanners.tsx index 12cd71c..c87705f 100644 --- a/components/recap/RecapBanners.tsx +++ b/components/recap/RecapBanners.tsx @@ -19,16 +19,18 @@ export default function RecapBanners({ moldChangeStartMs, offlineForMin, ongoing const moldStartLabel = moldChangeStartMs ? new Date(moldChangeStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }) : "--:--"; + const showOffline = offlineForMin != null && offlineForMin > 10; + const hideMoldBecauseOffline = showOffline && moldChangeStartMs != null; return (
- {moldChangeStartMs ? ( + {moldChangeStartMs && !hideMoldBecauseOffline ? (
{t("recap.banner.moldChange", { time: moldStartLabel })}
) : null} - {offlineForMin != null && offlineForMin > 10 ? ( + {showOffline ? (
{t("recap.banner.offline", { min: toInt(offlineForMin) })}
diff --git a/components/recap/RecapKpiRow.tsx b/components/recap/RecapKpiRow.tsx index 4c06179..cab188b 100644 --- a/components/recap/RecapKpiRow.tsx +++ b/components/recap/RecapKpiRow.tsx @@ -1,16 +1,26 @@ "use client"; import { useI18n } from "@/lib/i18n/useI18n"; +import type { RecapRangeMode } from "@/lib/recap/types"; type Props = { oeeAvg: number | null; goodParts: number; totalStops: number; scrapParts: number; + rangeMode?: RecapRangeMode; }; -export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts }: Props) { +export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts, rangeMode = "24h" }: Props) { const { t } = useI18n(); + const oeeLabel = + rangeMode === "shift" + ? t("recap.kpi.oeeShift") + : rangeMode === "yesterday" + ? t("recap.kpi.oeeYesterday") + : rangeMode === "custom" + ? t("recap.kpi.oeeCustom") + : t("recap.kpi.oee24h"); const items = [ { label: t("recap.kpi.good"), value: String(goodParts), valueClass: "text-white" }, @@ -24,7 +34,7 @@ export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts
{oeeAvg == null || Number.isNaN(oeeAvg) ? "—" : `${oeeAvg.toFixed(1)}%`}
-
{t("recap.kpi.oee")}
+
{oeeLabel}
{oeeAvg == null || Number.isNaN(oeeAvg) ? (
{t("recap.kpi.noData")}
) : null} diff --git a/fix2.md b/fix2.md new file mode 100644 index 0000000..5c833e8 --- /dev/null +++ b/fix2.md @@ -0,0 +1,42 @@ +Traced. Here's the truth for each filter. + +Range logic (lib/recap/redesign.ts line 458-490) +Filter Window computed What it shows +24h now - 24h → now Rolling last 24h. Mold change at right edge = started ~9:46pm today +Turno actual Current-shift window from orgShift table Needs configured shifts; else falls back to 24h +Ayer now-48h → now-24h (rolling!) NOT "yesterday 00:00-23:59" — it's "24-48h ago". Label is misleading +Personalizado User-picked Explicit +What's actually wrong +1. "Paros totales 3,102,444 min" and "144,409 min" are stale data. Those are huge because old duplicate ReasonEntry rows (from before the is_update/is_auto_ack filter was deployed) are still in DB. The filter stops NEW duplicates but doesn't delete old ones. + +Fix: + +TRUNCATE TABLE "ReasonEntry"; +Or targeted: + +DELETE FROM "ReasonEntry" WHERE "capturedAt" < ''; +After that, numbers will be realistic. + +2. "Ayer" window is wrong. Current code is rolling 48-24h ago, not calendar yesterday. Fix in resolveDetailRange: + +if (mode === "yesterday") { + // use local timezone calendar day, not rolling 24h + const tz = orgSettings.timezone ?? "America/Mexico_City"; + const today0 = zonedToUtcDate({ ...localDayStart(now, tz), hours: 0, minutes: 0, timeZone: tz }); + const yesterday0 = new Date(today0.getTime() - 24 * 60 * 60 * 1000); + return { mode, start: yesterday0, end: today0 }; +} +3. "Sin señal hace 376 min" is real. Pi stopped sending heartbeats 6h ago. Simultaneously "Cambio de molde en curso" is stuck active because Pi went offline DURING the mold change — no resolved event ever arrived. Both facts are true. Banner logic is correct, UX could be improved: + +If offlineMin > moldChangeAgeMin, show only the offline banner (more severe). Or combined: "Sin señal hace 376m — último estado: cambio de molde". + +4. Different OEE across filters is expected (different windows, different math). Labels should make it obvious: OEE PROMEDIO 24h, OEE DEL TURNO, OEE AYER. Currently they all say "OEE PROMEDIO 24H" regardless of filter → confusing. Check RecapKpiRow.tsx — the label should come from the range mode, not be hardcoded. + +5. Shift mode falls through to 24h if no shifts configured. That's why the numbers are slightly different — it actually ran with a real shift. Verify: SELECT * FROM "OrgShift" WHERE "orgId" = '';. If empty, shifts aren't set; the filter is silently showing 24h and labeling it "Turno actual" → more confusion. + +Priority order +Truncate ReasonEntry (kills 99% of the insanity). +Fix "Ayer" to be calendar-based. +Fix KPI row label to reflect selected range. +If no OrgShift rows exist, show a toast or disable "Turno actual" button instead of silently falling back. +Improve dual-banner priority (offline > mold-change). \ No newline at end of file diff --git a/lib/i18n/en.json b/lib/i18n/en.json index 0d7f5c8..5883809 100644 --- a/lib/i18n/en.json +++ b/lib/i18n/en.json @@ -129,10 +129,17 @@ "recap.range.yesterday": "Yesterday", "recap.range.custom": "Custom", "recap.range.apply": "Apply", + "recap.range.shiftUnavailable": "Current shift is unavailable because no shifts are configured.", + "recap.range.shiftFallbackUnavailable": "Current shift is unavailable. Showing the last 24h instead.", + "recap.range.shiftFallbackInactive": "No active shift right now. Showing the last 24h instead.", "recap.shift.1": "Shift 1", "recap.shift.2": "Shift 2", "recap.shift.3": "Shift 3", "recap.kpi.oee": "OEE Avg 24h", + "recap.kpi.oee24h": "OEE Avg 24h", + "recap.kpi.oeeShift": "OEE Shift", + "recap.kpi.oeeYesterday": "OEE Yesterday", + "recap.kpi.oeeCustom": "OEE Custom Range", "recap.kpi.noData": "No KPI data", "recap.kpi.good": "Good parts", "recap.kpi.stops": "Total stops (min)", diff --git a/lib/i18n/es-MX.json b/lib/i18n/es-MX.json index 912ee0c..bfe078a 100644 --- a/lib/i18n/es-MX.json +++ b/lib/i18n/es-MX.json @@ -129,10 +129,17 @@ "recap.range.yesterday": "Ayer", "recap.range.custom": "Personalizado", "recap.range.apply": "Aplicar", + "recap.range.shiftUnavailable": "Turno actual no disponible porque no hay turnos configurados.", + "recap.range.shiftFallbackUnavailable": "Turno actual no disponible. Mostrando últimas 24h.", + "recap.range.shiftFallbackInactive": "No hay turno activo en este momento. Mostrando últimas 24h.", "recap.shift.1": "Turno 1", "recap.shift.2": "Turno 2", "recap.shift.3": "Turno 3", "recap.kpi.oee": "OEE promedio 24h", + "recap.kpi.oee24h": "OEE promedio 24h", + "recap.kpi.oeeShift": "OEE del turno", + "recap.kpi.oeeYesterday": "OEE ayer", + "recap.kpi.oeeCustom": "OEE rango personalizado", "recap.kpi.noData": "Sin datos de KPI", "recap.kpi.good": "Buenas", "recap.kpi.stops": "Paros totales (min)", diff --git a/lib/recap/redesign.ts b/lib/recap/redesign.ts index f19367c..5291b27 100644 --- a/lib/recap/redesign.ts +++ b/lib/recap/redesign.ts @@ -389,7 +389,12 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) { }); const enabledShifts = shifts.filter((shift) => shift.enabled !== false); - if (!enabledShifts.length) return null; + if (!enabledShifts.length) { + return { + hasEnabledShifts: false, + range: null, + } as const; + } const timeZone = settings?.timezone || "UTC"; const local = getLocalParts(params.now, timeZone); @@ -447,57 +452,125 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) { if (end <= start) continue; return { - start, - end, + hasEnabledShifts: true, + range: { + start, + end, + }, }; } - return null; + return { + hasEnabledShifts: true, + range: null, + } as const; } async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) { const now = new Date(); - const mode = normalizedRangeMode(params.input.mode); + const requestedMode = normalizedRangeMode(params.input.mode); + const shiftEnabledCount = await prisma.orgShift.count({ + where: { + orgId: params.orgId, + enabled: { not: false }, + }, + }); + const shiftAvailable = shiftEnabledCount > 0; - if (mode === "custom") { + if (requestedMode === "custom") { const start = parseDate(params.input.start); const end = parseDate(params.input.end); if (start && end && end > start) { - return { mode, start, end }; - } - } - - if (mode === "yesterday") { - const end = new Date(now.getTime() - 24 * 60 * 60 * 1000); - const start = new Date(end.getTime() - 24 * 60 * 60 * 1000); - return { mode, start, end }; - } - - if (mode === "shift") { - const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now }); - if (shiftRange) { return { - mode, - start: shiftRange.start, - end: shiftRange.end, - }; + requestedMode, + mode: requestedMode, + start, + end, + shiftAvailable, + } as const; } } + if (requestedMode === "yesterday") { + const settings = await prisma.orgSettings.findUnique({ + where: { orgId: params.orgId }, + select: { timezone: true }, + }); + const timeZone = settings?.timezone || "America/Mexico_City"; + const localNow = getLocalParts(now, timeZone); + const today = { year: localNow.year, month: localNow.month, day: localNow.day }; + const yesterday = addDays(today, -1); + const start = zonedToUtcDate({ + ...yesterday, + hours: 0, + minutes: 0, + timeZone, + }); + const end = zonedToUtcDate({ + ...today, + hours: 0, + minutes: 0, + timeZone, + }); + return { + requestedMode, + mode: requestedMode, + start, + end, + shiftAvailable, + } as const; + } + + if (requestedMode === "shift") { + const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now }); + if (shiftRange.range) { + return { + requestedMode, + mode: requestedMode, + start: shiftRange.range.start, + end: shiftRange.range.end, + shiftAvailable, + } as const; + } + if (!shiftRange.hasEnabledShifts) { + return { + requestedMode, + mode: "24h" as const, + start: new Date(now.getTime() - 24 * 60 * 60 * 1000), + end: now, + shiftAvailable, + fallbackReason: "shift-unavailable" as const, + } as const; + } + return { + requestedMode, + mode: "24h" as const, + start: new Date(now.getTime() - 24 * 60 * 60 * 1000), + end: now, + shiftAvailable, + fallbackReason: "shift-inactive" as const, + } as const; + } + return { + requestedMode, mode: "24h" as const, start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end: now, - }; + shiftAvailable, + } as const; } async function computeRecapMachineDetail(params: { orgId: string; machineId: string; range: { + requestedMode: RecapRangeMode; mode: RecapRangeMode; start: Date; end: Date; + shiftAvailable: boolean; + fallbackReason?: "shift-unavailable" | "shift-inactive"; }; }) { const { range } = params; @@ -571,9 +644,12 @@ async function computeRecapMachineDetail(params: { const response: RecapDetailResponse = { generatedAt: new Date().toISOString(), range: { + requestedMode: range.requestedMode, mode: range.mode, start: range.start.toISOString(), end: range.end.toISOString(), + shiftAvailable: range.shiftAvailable, + fallbackReason: range.fallbackReason, }, machine: machineDetail, }; @@ -588,7 +664,10 @@ function summaryCacheKey(params: { orgId: string; hours: number }) { function detailCacheKey(params: { orgId: string; machineId: string; + requestedMode: RecapRangeMode; mode: RecapRangeMode; + shiftAvailable: boolean; + fallbackReason?: "shift-unavailable" | "shift-inactive"; startMs: number; endMs: number; }) { @@ -596,7 +675,10 @@ function detailCacheKey(params: { "recap-detail-v1", params.orgId, params.machineId, + params.requestedMode, params.mode, + params.shiftAvailable ? "shift-on" : "shift-off", + params.fallbackReason ?? "", String(Math.trunc(params.startMs / 60000)), String(Math.trunc(params.endMs / 60000)), ]; @@ -657,15 +739,21 @@ export async function getRecapMachineDetailCached(params: { orgId: params.orgId, machineId: params.machineId, range: { + requestedMode: resolved.requestedMode, mode: resolved.mode, start: resolved.start, end: resolved.end, + shiftAvailable: resolved.shiftAvailable, + fallbackReason: resolved.fallbackReason, }, }), detailCacheKey({ orgId: params.orgId, machineId: params.machineId, + requestedMode: resolved.requestedMode, mode: resolved.mode, + shiftAvailable: resolved.shiftAvailable, + fallbackReason: resolved.fallbackReason, startMs: resolved.start.getTime(), endMs: resolved.end.getTime(), }), diff --git a/lib/recap/types.ts b/lib/recap/types.ts index 4735fba..1828ec3 100644 --- a/lib/recap/types.ts +++ b/lib/recap/types.ts @@ -211,9 +211,12 @@ export type RecapMachineDetail = { export type RecapDetailResponse = { generatedAt: string; range: { + requestedMode?: RecapRangeMode; mode: RecapRangeMode; start: string; end: string; + shiftAvailable?: boolean; + fallbackReason?: "shift-unavailable" | "shift-inactive"; }; machine: RecapMachineDetail; };