1, 'downloads' => 3, 'favourites' => 6, 'comments' => 8, 'shares' => 12, ]; public function handle(): int { $days = (int) $this->option('days'); $chunk = (int) $this->option('chunk'); $lookbackHours = max(1, (int) $this->option('lookback-hours')); $dryRun = (bool) $this->option('dry-run'); $now = now(); $currentHour = $now->copy()->startOfHour(); $prevHour = $currentHour->copy()->subHour(); $lookbackStart = $currentHour->copy()->subHours($lookbackHours); $this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} lookback_start={$lookbackStart->toDateTimeString()} lookback_hours={$lookbackHours} days={$days}" . ($dryRun ? ' (dry-run)' : '')); $updatedCount = 0; $skippedCount = 0; // Process in chunks using artwork IDs that have at least one snapshot in the smoothing window $artworkIds = DB::table('artwork_metric_snapshots_hourly') ->whereBetween('bucket_hour', [$lookbackStart, $currentHour]) ->distinct() ->pluck('artwork_id'); if ($artworkIds->isEmpty()) { $this->warn('No snapshots found inside the requested lookback window. Run nova:metrics-snapshot-hourly first.'); return self::SUCCESS; } // Load all snapshots for the lookback window in bulk $snapshots = DB::table('artwork_metric_snapshots_hourly') ->whereBetween('bucket_hour', [$lookbackStart, $currentHour]) ->whereIn('artwork_id', $artworkIds) ->orderBy('bucket_hour') ->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()); if (! $currentSnapshot) { $currentSnapshot = $artworkSnapshots->last(); } $prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString()); $baselineSnapshot = $artworkSnapshots ->filter(fn ($snapshot) => (string) $snapshot->bucket_hour < (string) ($currentSnapshot->bucket_hour ?? '')) ->first(); if (! $currentSnapshot) { $skippedCount++; continue; } // One-hour counters remain explicit fields for dashboards and debugging. $viewsDelta1h = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0)); $downloadsDelta1h = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0)); $favouritesDelta1h = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0)); $commentsDelta1h = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0)); $sharesDelta1h = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0)); // Smooth the heat signal over a trailing window so low-traffic periods do not flatten Rising. // A single snapshot without an earlier baseline should not count as new momentum. if ($baselineSnapshot) { $viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($baselineSnapshot->views_count ?? 0)); $downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($baselineSnapshot->downloads_count ?? 0)); $favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($baselineSnapshot->favourites_count ?? 0)); $commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($baselineSnapshot->comments_count ?? 0)); $sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($baselineSnapshot->shares_count ?? 0)); $windowHours = max( 1.0, abs($currentHour->copy()->parse($currentSnapshot->bucket_hour)->floatDiffInHours($currentHour->copy()->parse($baselineSnapshot->bucket_hour))) ); } else { $viewsDelta = 0; $downloadsDelta = 0; $favouritesDelta = 0; $commentsDelta = 0; $sharesDelta = 0; $windowHours = 1.0; } // Raw heat $rawHeat = ( ($viewsDelta * self::WEIGHTS['views']) + ($downloadsDelta * self::WEIGHTS['downloads']) + ($favouritesDelta * self::WEIGHTS['favourites']) + ($commentsDelta * self::WEIGHTS['comments']) + ($sharesDelta * self::WEIGHTS['shares']) ) / $windowHours; // 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' => $viewsDelta1h, 'downloads_1h' => $downloadsDelta1h, 'favourites_1h' => $favouritesDelta1h, 'comments_1h' => $commentsDelta1h, 'shares_1h' => $sharesDelta1h, ]; $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; } }