Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal file
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Collect hourly metric snapshots for artworks.
|
||||
*
|
||||
* Runs on cron every hour. Inserts a row per artwork into
|
||||
* artwork_metric_snapshots_hourly with the current totals.
|
||||
* Deltas are computed by the heat recalculation command.
|
||||
*
|
||||
* Usage: php artisan nova:metrics-snapshot-hourly
|
||||
* php artisan nova:metrics-snapshot-hourly --days=30 --chunk=500 --dry-run
|
||||
*/
|
||||
class MetricsSnapshotHourlyCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:metrics-snapshot-hourly
|
||||
{--days=60 : Only snapshot artworks created within this many days}
|
||||
{--chunk=1000 : Chunk size for DB queries}
|
||||
{--dry-run : Log what would be written without persisting}';
|
||||
|
||||
protected $description = 'Collect hourly metric snapshots for rising/heat calculation';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$bucketHour = now()->startOfHour();
|
||||
|
||||
$this->info("[nova:metrics-snapshot-hourly] bucket={$bucketHour->toDateTimeString()} days={$days} chunk={$chunk}" . ($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$snapshotCount = 0;
|
||||
$skipCount = 0;
|
||||
|
||||
// Query artworks eligible for snapshotting:
|
||||
// - created within $days OR has a ranking_score above 0
|
||||
// First collect eligible IDs, then process in chunks
|
||||
$eligibleIds = DB::table('artworks')
|
||||
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
|
||||
->where(function ($q) use ($days) {
|
||||
$q->where('artworks.created_at', '>=', now()->subDays($days))
|
||||
->orWhere(function ($q2) {
|
||||
$q2->whereNotNull('s.ranking_score')
|
||||
->where('s.ranking_score', '>', 0);
|
||||
});
|
||||
})
|
||||
->whereNull('artworks.deleted_at')
|
||||
->where('artworks.is_approved', true)
|
||||
->pluck('artworks.id');
|
||||
|
||||
if ($eligibleIds->isEmpty()) {
|
||||
$this->info('No eligible artworks found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($eligibleIds->chunk($chunk) as $chunkIds) {
|
||||
$artworkIds = $chunkIds->values()->all();
|
||||
|
||||
$stats = DB::table('artwork_stats')
|
||||
->whereIn('artwork_id', $artworkIds)
|
||||
->get()
|
||||
->keyBy('artwork_id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($artworkIds as $artworkId) {
|
||||
$stat = $stats->get($artworkId);
|
||||
|
||||
$rows[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'bucket_hour' => $bucketHour,
|
||||
'views_count' => (int) ($stat?->views ?? 0),
|
||||
'downloads_count' => (int) ($stat?->downloads ?? 0),
|
||||
'favourites_count' => (int) ($stat?->favorites ?? 0),
|
||||
'comments_count' => (int) ($stat?->comments_count ?? 0),
|
||||
'shares_count' => (int) ($stat?->shares_count ?? 0),
|
||||
'created_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$snapshotCount += count($rows);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($rows)) {
|
||||
// Upsert: if (artwork_id, bucket_hour) already exists, update totals
|
||||
DB::table('artwork_metric_snapshots_hourly')->upsert(
|
||||
$rows,
|
||||
['artwork_id', 'bucket_hour'],
|
||||
['views_count', 'downloads_count', 'favourites_count', 'comments_count', 'shares_count']
|
||||
);
|
||||
|
||||
$snapshotCount += count($rows);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Snapshots written: {$snapshotCount} | Skipped: {$skipCount}");
|
||||
|
||||
Log::info('[nova:metrics-snapshot-hourly] completed', [
|
||||
'bucket' => $bucketHour->toDateTimeString(),
|
||||
'written' => $snapshotCount,
|
||||
'skipped' => $skipCount,
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal file
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Prune old hourly metric snapshots to prevent unbounded table growth.
|
||||
*
|
||||
* Usage: php artisan nova:prune-metric-snapshots
|
||||
* php artisan nova:prune-metric-snapshots --keep-days=7
|
||||
*/
|
||||
class PruneMetricSnapshotsCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:prune-metric-snapshots
|
||||
{--keep-days=7 : Keep snapshots for this many days}';
|
||||
|
||||
protected $description = 'Delete old hourly metric snapshots beyond the retention window';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$keepDays = (int) $this->option('keep-days');
|
||||
$cutoff = now()->subDays($keepDays);
|
||||
|
||||
$deleted = DB::table('artwork_metric_snapshots_hourly')
|
||||
->where('bucket_hour', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Pruned {$deleted} snapshot rows older than {$keepDays} days.");
|
||||
|
||||
Log::info('[nova:prune-metric-snapshots] completed', [
|
||||
'deleted' => $deleted,
|
||||
'keep_days' => $keepDays,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
166
app/Console/Commands/RecalculateHeatCommand.php
Normal file
166
app/Console/Commands/RecalculateHeatCommand.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Recalculate heat_score for artworks based on hourly metric snapshots.
|
||||
*
|
||||
* Runs every 10–15 minutes via scheduler.
|
||||
*
|
||||
* Formula:
|
||||
* raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6
|
||||
* + comments_delta*8 + shares_delta*12
|
||||
*
|
||||
* age_factor = 1 / (1 + hours_since_upload / 24)
|
||||
*
|
||||
* heat_score = raw_heat * age_factor
|
||||
*
|
||||
* Usage: php artisan nova:recalculate-heat
|
||||
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --dry-run
|
||||
*/
|
||||
class RecalculateHeatCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:recalculate-heat
|
||||
{--days=60 : Only process artworks created within this many days}
|
||||
{--chunk=1000 : Chunk size for DB queries}
|
||||
{--dry-run : Compute scores without writing to DB}';
|
||||
|
||||
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
|
||||
|
||||
/** Delta weights per the spec */
|
||||
private const WEIGHTS = [
|
||||
'views' => 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user