started_at ?? $run->created_at; if (! $start) { return null; } $end = $run->completed_at ?? now(); $seconds = $end->diffInSeconds($start); if (is_int($seconds)) { return $seconds; } return (int) round((float) $seconds); } public static function elapsedHuman(OperationRun $run): string { $start = $run->started_at ?? $run->created_at; if (! $start) { return '—'; } $end = $run->completed_at ?? now(); return $end->diffForHumans($start, true); } public static function expectedSeconds(OperationRun $run): ?int { $catalog = OperationCatalog::expectedDurationSeconds((string) $run->type); if (is_int($catalog)) { return $catalog; } $tenantId = (int) ($run->tenant_id ?? 0); $type = (string) ($run->type ?? ''); if ($tenantId <= 0 || $type === '') { return null; } $cacheKey = "opsux:expected-duration:tenant:{$tenantId}:type:".$type; return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($tenantId, $type): ?int { $durations = OperationRun::query() ->where('tenant_id', $tenantId) ->where('type', $type) ->whereNotNull('started_at') ->whereNotNull('completed_at') ->where('created_at', '>=', now()->subDays(30)) ->latest('id') ->limit(200) ->get(['started_at', 'completed_at']) ->map(function (OperationRun $run): ?int { if (! $run->started_at || ! $run->completed_at) { return null; } $seconds = $run->completed_at->diffInSeconds($run->started_at); if (is_int($seconds)) { return $seconds; } return (int) round((float) $seconds); }) ->filter(fn (?int $seconds): bool => is_int($seconds) && $seconds > 0) ->sort() ->values(); if ($durations->isEmpty()) { return null; } $count = $durations->count(); $middle = intdiv($count - 1, 2); $median = (int) $durations[$middle]; return $median > 0 ? $median : null; }); } public static function expectedHuman(OperationRun $run): ?string { $seconds = self::expectedSeconds($run); if (! is_int($seconds) || $seconds <= 0) { return null; } if ($seconds < 60) { return 'Typically under 1 minute'; } $minutes = (int) round($seconds / 60); return "Typically ~{$minutes} min"; } public static function stuckGuidance(OperationRun $run): ?string { $uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome); if (! in_array($uxStatus, ['queued', 'running'], true)) { return null; } $elapsed = self::elapsedSeconds($run); if (! is_int($elapsed) || $elapsed <= 0) { return null; } $expected = self::expectedSeconds($run); $isLikelyStuck = is_int($expected) ? ($elapsed > max(600, $expected * 2)) : ($elapsed > 900); if (! $isLikelyStuck) { return null; } return 'Taking longer than expected. If this does not complete soon, verify the queue worker is running and check logs for errors.'; } }