import { unstable_cache } from "next/cache"; import { prisma } from "@/lib/prisma"; import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings"; import { getRecapDataCached } from "@/lib/recap/getRecapData"; import { buildTimelineSegments, compressTimelineSegments, TIMELINE_EVENT_TYPES, type TimelineCycleRow, type TimelineEventRow, } from "@/lib/recap/timeline"; import { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState"; import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants"; import type { RecapDetailResponse, RecapMachine, RecapMachineDetail, RecapMachineStatus, RecapRangeMode, RecapStateContext, RecapSummaryMachine, RecapSummaryResponse, } from "@/lib/recap/types"; type DetailRangeInput = { mode?: string | null; start?: string | null; end?: string | null; }; 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 = { Mon: "mon", Tue: "tue", Wed: "wed", Thu: "thu", Fri: "fri", Sat: "sat", Sun: "sun", }; function round2(value: number) { return Math.round(value * 100) / 100; } function parseDate(input?: string | null) { if (!input) return null; const n = Number(input); if (Number.isFinite(n)) { const d = new Date(n); return Number.isFinite(d.getTime()) ? d : null; } const d = new Date(input); return Number.isFinite(d.getTime()) ? d : null; } function parseHours(input: string | null) { const parsed = Math.trunc(Number(input ?? "24")); if (!Number.isFinite(parsed)) return 24; return Math.max(1, Math.min(72, parsed)); } function parseTimeMinutes(input?: string | null) { if (!input) return null; const match = /^(\d{2}):(\d{2})$/.exec(input.trim()); if (!match) return null; const hours = Number(match[1]); const minutes = Number(match[2]); if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { return null; } return hours * 60 + minutes; } function getLocalParts(ts: Date, timeZone: string) { try { const parts = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", weekday: "short", hour12: false, }).formatToParts(ts); const value = (type: string) => parts.find((part) => part.type === type)?.value ?? ""; const year = Number(value("year")); const month = Number(value("month")); const day = Number(value("day")); const hour = Number(value("hour")); const minute = Number(value("minute")); const weekday = value("weekday"); return { year, month, day, hour, minute, weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()], minutesOfDay: hour * 60 + minute, }; } catch { return { year: ts.getUTCFullYear(), month: ts.getUTCMonth() + 1, day: ts.getUTCDate(), hour: ts.getUTCHours(), minute: ts.getUTCMinutes(), weekday: WEEKDAY_KEYS[ts.getUTCDay()], minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(), }; } } function parseOffsetMinutes(offsetLabel: string | null) { if (!offsetLabel) return null; const normalized = offsetLabel.replace("UTC", "GMT"); const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized); if (!match) return null; const sign = match[1] === "-" ? -1 : 1; const hour = Number(match[2]); const minute = Number(match[3] ?? "0"); if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; return sign * (hour * 60 + minute); } function getTzOffsetMinutes(utcDate: Date, timeZone: string) { try { const parts = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "shortOffset", hour: "2-digit", }).formatToParts(utcDate); const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null; return parseOffsetMinutes(offsetPart); } catch { return null; } } function zonedToUtcDate(input: { year: number; month: number; day: number; hours: number; minutes: number; timeZone: string; }) { const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0); const guessDate = new Date(baseUtc); const offsetA = getTzOffsetMinutes(guessDate, input.timeZone); if (offsetA == null) return guessDate; let corrected = new Date(baseUtc - offsetA * 60000); const offsetB = getTzOffsetMinutes(corrected, input.timeZone); if (offsetB != null && offsetB !== offsetA) { corrected = new Date(baseUtc - offsetB * 60000); } return corrected; } function addDays(input: { year: number; month: number; day: number }, days: number) { const base = new Date(Date.UTC(input.year, input.month - 1, input.day)); base.setUTCDate(base.getUTCDate() + days); return { year: base.getUTCFullYear(), month: base.getUTCMonth() + 1, day: base.getUTCDate(), }; } // Detect active episodes (macrostop, mold-change) from event rows. // Returns the latest non-auto-ack episode whose final status is "active" // and that's been refreshed within ACTIVE_STALE_MS. const ACTIVE_STALE_MS = 2 * 60 * 1000; type ActiveEpisode = { startedAtMs: number; lastTsMs: number }; function detectActiveEpisode( events: TimelineEventRow[] | undefined, eventType: "macrostop" | "mold-change", endMs: number ): ActiveEpisode | null { if (!events || events.length === 0) return null; type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null }; const episodes = new Map(); for (const event of events) { if (String(event.eventType || "").toLowerCase() !== eventType) continue; let parsed: unknown = event.data; if (typeof parsed === "string") { try { parsed = JSON.parse(parsed); } catch { parsed = null; } } const data: Record = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; const isAutoAck = data.is_auto_ack === true || data.isAutoAck === true || data.is_auto_ack === "true" || data.isAutoAck === "true"; if (isAutoAck) continue; const status = String(data.status ?? "").trim().toLowerCase(); const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim() || `${eventType}:${event.ts.getTime()}`; const tsMs = event.ts.getTime(); const lastCycleTs = Number(data.last_cycle_timestamp); const existing = episodes.get(incidentKey); if (!existing) { episodes.set(incidentKey, { firstTsMs: tsMs, lastTsMs: tsMs, lastStatus: status, lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null, }); continue; } existing.firstTsMs = Math.min(existing.firstTsMs, tsMs); if (tsMs >= existing.lastTsMs) { existing.lastTsMs = tsMs; existing.lastStatus = status; } } let best: ActiveEpisode | null = null; for (const ep of episodes.values()) { if (ep.lastStatus !== "active") continue; if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue; // Prefer the freshest active episode (highest lastTsMs) if (!best || ep.lastTsMs > best.lastTsMs) { best = { startedAtMs: ep.lastCycleTs ?? ep.firstTsMs, lastTsMs: ep.lastTsMs, }; } } return best; } function statusFromMachine( machine: RecapMachine, endMs: number, events?: TimelineEventRow[] ): { status: RecapMachineStatus; result: MachineStateResult; stateContext: RecapStateContext; lastSeenMs: number | null; offlineForMin: number | null; ongoingStopMin: number | null; } { const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null; const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs); const heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS; const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs); const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs); // Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries // we don't yet plumb here. We approximate from the legacy fields: // - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on) // OR when an active WO exists and machine.workOrders.moldChangeInProgress is false. // This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read. // - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI) // // Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking // is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet. // IDLE fires correctly when there's no WO and no recent activity. const hasActiveWorkOrder = machine.workOrders.active != null; const trackingEnabledApprox = hasActiveWorkOrder; // see comment above const lastCycleTsMs = (() => { // Best-effort: use the machine's heartbeat as a "recent activity" proxy. // The Pi only heartbeats every minute regardless of cycles, so this is a weak signal. // Round 3 will pass the actual latest cycle ts. return lastSeenMs; })(); const result = classifyMachineState( { heartbeatAlive, lastSeenMs, offlineForMs, trackingEnabled: trackingEnabledApprox, hasActiveWorkOrder, activeMoldChange, activeMacrostop, untrackedCycles: { count: 0, oldestTsMs: null }, lastCycleTsMs, }, endMs ); // Map the rich classifier result back to the existing RecapMachineStatus union const status: RecapMachineStatus = result.state; // Pull common fields out for the caller's convenience let ongoingStopMin: number | null = null; if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin; let stateContext: RecapStateContext = { stoppedReason: null, dataLossReason: null, untrackedCycleCount: null, }; if (result.state === "stopped") { stateContext = { stoppedReason: result.reason, dataLossReason: null, untrackedCycleCount: null, }; } else if (result.state === "data-loss") { stateContext = { stoppedReason: null, dataLossReason: result.reason, untrackedCycleCount: result.untrackedCycleCount, }; } return { status, result, stateContext, lastSeenMs, offlineForMin: result.state === "offline" ? result.offlineForMin : null, ongoingStopMin, }; } async function loadTimelineRowsForMachines(params: { orgId: string; machineIds: string[]; start: Date; end: Date; }) { if (!params.machineIds.length) { return { cyclesByMachine: new Map(), eventsByMachine: new Map(), }; } const [cycles, events] = await Promise.all([ prisma.machineCycle.findMany({ where: { orgId: params.orgId, machineId: { in: params.machineIds }, ts: { gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS), lte: params.end, }, }, orderBy: [{ machineId: "asc" }, { ts: "asc" }], select: { machineId: true, ts: true, cycleCount: true, actualCycleTime: true, workOrderId: true, sku: true, }, }), prisma.machineEvent.findMany({ where: { orgId: params.orgId, machineId: { in: params.machineIds }, eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] }, ts: { gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS), lte: params.end, }, }, orderBy: [{ machineId: "asc" }, { ts: "asc" }], select: { machineId: true, ts: true, eventType: true, data: true, }, }), ]); const cyclesByMachine = new Map(); const eventsByMachine = new Map(); for (const row of cycles) { const list = cyclesByMachine.get(row.machineId) ?? []; list.push({ ts: row.ts, cycleCount: row.cycleCount, actualCycleTime: row.actualCycleTime, workOrderId: row.workOrderId, sku: row.sku, }); cyclesByMachine.set(row.machineId, list); } for (const row of events) { const list = eventsByMachine.get(row.machineId) ?? []; list.push({ ts: row.ts, eventType: row.eventType, data: row.data, }); eventsByMachine.set(row.machineId, list); } return { cyclesByMachine, eventsByMachine }; } function toSummaryMachine(params: { machine: RecapMachine; miniTimeline: ReturnType; rangeEndMs: number; events?: TimelineEventRow[]; }): RecapSummaryMachine { const { machine, miniTimeline, rangeEndMs, events } = params; const status = statusFromMachine(machine, rangeEndMs, events); return { machineId: machine.machineId, name: machine.machineName, location: machine.location, status: status.status, oee: machine.oee.avg, goodParts: machine.production.goodParts, scrap: machine.production.scrapParts, stopsCount: machine.downtime.stopsCount, lastSeenMs: status.lastSeenMs, lastActivityMin: status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)), offlineForMin: status.offlineForMin, ongoingStopMin: status.ongoingStopMin, stateContext: status.stateContext, activeWorkOrderId: machine.workOrders.active?.id ?? null, moldChange: { active: machine.workOrders.moldChangeInProgress, startMs: machine.workOrders.moldChangeStartMs, elapsedMin: machine.workOrders.moldChangeStartMs == null ? null : Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)), }, miniTimeline, }; } async function computeRecapSummary(params: { orgId: string; hours: number }) { const now = new Date(); const end = new Date(Math.floor(now.getTime() / 60000) * 60000); const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000); const recap = await getRecapDataCached({ orgId: params.orgId, start, end, }); const machineIds = recap.machines.map((machine) => machine.machineId); const timelineRows = await loadTimelineRowsForMachines({ orgId: params.orgId, machineIds, start, end, }); const machines = recap.machines.map((machine) => { const segments = buildTimelineSegments({ cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [], events: timelineRows.eventsByMachine.get(machine.machineId) ?? [], rangeStart: start, rangeEnd: end, }); const miniTimeline = compressTimelineSegments({ segments, rangeStart: start, rangeEnd: end, maxSegments: 60, }); return toSummaryMachine({ machine, miniTimeline, rangeEndMs: end.getTime(), events: timelineRows.eventsByMachine.get(machine.machineId), }); }); const response: RecapSummaryResponse = { generatedAt: new Date().toISOString(), range: { start: start.toISOString(), end: end.toISOString(), hours: params.hours, }, machines, }; return response; } function normalizedRangeMode(mode?: string | null): RecapRangeMode { const raw = String(mode ?? "").trim().toLowerCase(); if (raw === "shift") return "shift"; if (raw === "yesterday") return "yesterday"; if (raw === "custom") return "custom"; return "24h"; } async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) { const settings = await prisma.orgSettings.findUnique({ where: { orgId: params.orgId }, select: { timezone: true, shiftScheduleOverridesJson: true, }, }); const shifts = await prisma.orgShift.findMany({ where: { orgId: params.orgId }, orderBy: { sortOrder: "asc" }, select: { name: true, startTime: true, endTime: true, enabled: true, sortOrder: true, }, }); const enabledShifts = shifts.filter((shift) => shift.enabled !== false); if (!enabledShifts.length) { return { hasEnabledShifts: false, range: null, } as const; } const timeZone = settings?.timezone || "UTC"; const local = getLocalParts(params.now, timeZone); const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson); const dayOverrides = overrides?.[local.weekday]; const activeShifts = (dayOverrides?.length ? dayOverrides.map((shift) => ({ enabled: shift.enabled !== false, start: shift.start, end: shift.end, })) : enabledShifts.map((shift) => ({ enabled: shift.enabled !== false, start: shift.startTime, end: shift.endTime, })) ).filter((shift) => shift.enabled); for (const shift of activeShifts) { const startMin = parseTimeMinutes(shift.start ?? null); const endMin = parseTimeMinutes(shift.end ?? null); if (startMin == null || endMin == null) continue; const minutesNow = local.minutesOfDay; let inRange = false; let startDate = { year: local.year, month: local.month, day: local.day }; let endDate = { year: local.year, month: local.month, day: local.day }; if (startMin <= endMin) { inRange = minutesNow >= startMin && minutesNow < endMin; } else { inRange = minutesNow >= startMin || minutesNow < endMin; if (minutesNow >= startMin) { endDate = addDays(endDate, 1); } else { startDate = addDays(startDate, -1); } } if (!inRange) continue; const start = zonedToUtcDate({ ...startDate, hours: Math.floor(startMin / 60), minutes: startMin % 60, timeZone, }); const shiftEndUtc = zonedToUtcDate({ ...endDate, hours: Math.floor(endMin / 60), minutes: endMin % 60, timeZone, }); 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 }, }; } return { hasEnabledShifts: true, range: null, } as const; } async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) { const now = new Date(Math.floor(Date.now() / 60000) * 60000); const requestedMode = normalizedRangeMode(params.input.mode); const shiftEnabledCount = await prisma.orgShift.count({ where: { orgId: params.orgId, enabled: { not: false }, }, }); const shiftAvailable = shiftEnabledCount > 0; if (requestedMode === "custom") { const start = parseDate(params.input.start); const end = parseDate(params.input.end); if (start && end && end > start) { return { 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; const recap = await getRecapDataCached({ orgId: params.orgId, machineId: params.machineId, start: range.start, end: range.end, }); const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null; if (!machine) return null; const timelineRows = await loadTimelineRowsForMachines({ orgId: params.orgId, machineIds: [params.machineId], start: range.start, end: range.end, }); const timeline = buildTimelineSegments({ cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [], events: timelineRows.eventsByMachine.get(params.machineId) ?? [], rangeStart: range.start, rangeEnd: range.end, }); const status = statusFromMachine( machine, range.end.getTime(), timelineRows.eventsByMachine.get(params.machineId) ); const downtimeTotalMin = Math.max(0, machine.downtime.totalMin); const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({ reasonLabel: row.reasonLabel, minutes: row.minutes, count: row.count, percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0, })); const machineDetail: RecapMachineDetail = { machineId: machine.machineId, name: machine.machineName, location: machine.location, status: status.status, oee: machine.oee.avg, goodParts: machine.production.goodParts, scrap: machine.production.scrapParts, stopsCount: machine.downtime.stopsCount, stopMinutes: downtimeTotalMin, activeWorkOrderId: machine.workOrders.active?.id ?? null, lastSeenMs: status.lastSeenMs, offlineForMin: status.offlineForMin, ongoingStopMin: status.ongoingStopMin, stateContext: status.stateContext, moldChange: { active: machine.workOrders.moldChangeInProgress, startMs: machine.workOrders.moldChangeStartMs, }, timeline, productionBySku: machine.production.bySku, downtimeTop, workOrders: { completed: machine.workOrders.completed, active: machine.workOrders.active, }, heartbeat: { lastSeenAt: machine.heartbeat.lastSeenAt, uptimePct: machine.heartbeat.uptimePct, connectionStatus: status.status === "offline" ? "offline" : "online", }, }; const response: RecapDetailResponse = { generatedAt: new Date().toISOString(), range: { requestedMode: range.requestedMode, mode: range.mode, start: range.start.toISOString(), end: range.end.toISOString(), shiftAvailable: range.shiftAvailable, fallbackReason: range.fallbackReason, }, machine: machineDetail, }; return response; } function summaryCacheKey(params: { orgId: string; hours: number }) { return ["recap-summary-v1", params.orgId, String(params.hours)]; } function detailCacheKey(params: { orgId: string; machineId: string; requestedMode: RecapRangeMode; mode: RecapRangeMode; shiftAvailable: boolean; fallbackReason?: "shift-unavailable" | "shift-inactive"; startMs: number; endMs: number; }) { return [ "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)), ]; } export function parseRecapSummaryHours(raw: string | null) { return parseHours(raw); } export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record) { if (searchParams instanceof URLSearchParams) { return { mode: searchParams.get("range") ?? undefined, start: searchParams.get("start") ?? undefined, end: searchParams.get("end") ?? undefined, }; } const pick = (key: string) => { const value = searchParams[key]; if (Array.isArray(value)) return value[0] ?? undefined; return value ?? undefined; }; return { mode: pick("range"), start: pick("start"), end: pick("end"), }; } export async function getRecapSummaryCached(params: { orgId: string; hours: number }) { const cache = unstable_cache( () => computeRecapSummary(params), summaryCacheKey(params), { revalidate: RECAP_CACHE_TTL_SEC, tags: [`recap:${params.orgId}`], } ); return cache(); } export async function getRecapMachineDetailCached(params: { orgId: string; machineId: string; input: DetailRangeInput; }) { const resolved = await resolveDetailRange({ orgId: params.orgId, input: params.input, }); const cache = unstable_cache( () => computeRecapMachineDetail({ orgId: params.orgId, machineId: params.machineId, range: { 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(), }), { revalidate: RECAP_CACHE_TTL_SEC, tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`], } ); return cache(); }