1, 'downloads' => 3, 'favourites' => 6, 'comments' => 8, 'shares' => 12, ]; public function handle(): int { $days = (int) $this->option('days'); $chunk = (int) $this->option('chunk'); $dryRun = (bool) $this->option('dry-run'); $now = now(); $currentHour = $now->copy()->startOfHour(); $prevHour = $currentHour->copy()->subHour(); $this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : '')); $updatedCount = 0; $skippedCount = 0; // Process in chunks using artwork IDs that have at least one snapshot in the two hours $artworkIds = DB::table('artwork_metric_snapshots_hourly') ->whereIn('bucket_hour', [$currentHour, $prevHour]) ->distinct() ->pluck('artwork_id'); if ($artworkIds->isEmpty()) { $this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.'); return self::SUCCESS; } // Load all snapshots for the two hours in bulk $snapshots = DB::table('artwork_metric_snapshots_hourly') ->whereIn('bucket_hour', [$currentHour, $prevHour]) ->whereIn('artwork_id', $artworkIds) ->get() ->groupBy('artwork_id'); // Load artwork published_at dates for age factor (use published_at, fall back to created_at) $artworkDates = DB::table('artworks') ->whereIn('id', $artworkIds) ->whereNull('deleted_at') ->where('is_approved', true) ->select('id', 'published_at', 'created_at') ->get() ->mapWithKeys(fn ($row) => [ $row->id => \Carbon\Carbon::parse($row->published_at ?? $row->created_at), ]); // Process in chunks foreach ($artworkIds->chunk($chunk) as $chunkIds) { $upsertRows = []; foreach ($chunkIds as $artworkId) { $createdAt = $artworkDates->get($artworkId); if (!$createdAt) { $skippedCount++; continue; } $artworkSnapshots = $snapshots->get($artworkId); if (!$artworkSnapshots || $artworkSnapshots->isEmpty()) { $skippedCount++; continue; } $currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString()); $prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString()); // If we only have one snapshot, use it as current with zero deltas if (!$currentSnapshot && !$prevSnapshot) { $skippedCount++; continue; } // Calculate deltas $viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0)); $downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0)); $favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0)); $commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0)); $sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0)); // Raw heat $rawHeat = ($viewsDelta * self::WEIGHTS['views']) + ($downloadsDelta * self::WEIGHTS['downloads']) + ($favouritesDelta * self::WEIGHTS['favourites']) + ($commentsDelta * self::WEIGHTS['comments']) + ($sharesDelta * self::WEIGHTS['shares']); // Age factor: favors newer works $hoursSinceUpload = abs($now->floatDiffInHours($createdAt)); $ageFactor = 1.0 / (1.0 + ($hoursSinceUpload / 24.0)); // Final heat score $heatScore = max(0, $rawHeat * $ageFactor); $upsertRows[] = [ 'artwork_id' => $artworkId, 'heat_score' => round($heatScore, 4), 'heat_score_updated_at' => $now, 'views_1h' => $viewsDelta, 'downloads_1h' => $downloadsDelta, 'favourites_1h' => $favouritesDelta, 'comments_1h' => $commentsDelta, 'shares_1h' => $sharesDelta, ]; $updatedCount++; } if (!$dryRun && !empty($upsertRows)) { DB::table('artwork_stats')->upsert( $upsertRows, ['artwork_id'], ['heat_score', 'heat_score_updated_at', 'views_1h', 'downloads_1h', 'favourites_1h', 'comments_1h', 'shares_1h'] ); } } $this->info("Heat scores updated: {$updatedCount} | Skipped: {$skippedCount}"); Log::info('[nova:recalculate-heat] completed', [ 'updated' => $updatedCount, 'skipped' => $skippedCount, 'dry_run' => $dryRun, ]); return self::SUCCESS; } }