tableCoverage($days); $empty = $this->emptyResult($days, $coverage); if ($userId <= 0 || ! Schema::hasTable('artwork_metric_snapshots_hourly')) { return $empty; } $since = now()->subDays($days); $row = DB::query() ->fromSub($this->artworkPeriodDeltas($since, $userId), 'deltas') ->selectRaw('COALESCE(SUM(views_delta), 0) as views') ->selectRaw('COALESCE(SUM(downloads_delta), 0) as downloads') ->selectRaw('COALESCE(SUM(favourites_delta), 0) as favourites') ->selectRaw('COALESCE(SUM(comments_delta), 0) as comments') ->selectRaw('COALESCE(SUM(shares_delta), 0) as shares') ->first(); $userBounds = DB::table('artwork_metric_snapshots_hourly as snapshots') ->join('artworks', 'artworks.id', '=', 'snapshots.artwork_id') ->where('artworks.user_id', $userId) ->whereNull('artworks.deleted_at') ->where('snapshots.bucket_hour', '>=', $since) ->selectRaw('MIN(snapshots.bucket_hour) as oldest_bucket') ->selectRaw('MAX(snapshots.bucket_hour) as newest_bucket') ->first(); $userCoverageDays = $this->coverageDays( $userBounds->oldest_bucket ?? null, $userBounds->newest_bucket ?? null, ); return [ 'views' => (int) ($row->views ?? 0), 'downloads' => (int) ($row->downloads ?? 0), 'favourites' => (int) ($row->favourites ?? 0), 'comments' => (int) ($row->comments ?? 0), 'shares' => (int) ($row->shares ?? 0), 'requested_days' => $days, 'table_coverage_days' => $coverage['coverage_days'], 'user_coverage_days' => $userCoverageDays, 'window_complete' => $coverage['window_complete'], 'oldest_bucket' => $coverage['oldest_bucket'], 'newest_bucket' => $coverage['newest_bucket'], 'user_oldest_bucket' => $userBounds->oldest_bucket ?? null, 'user_newest_bucket' => $userBounds->newest_bucket ?? null, ]; } /** * Per-artwork non-negative period deltas for cumulative snapshot columns. */ public function artworkPeriodDeltas(CarbonInterface|\DateTimeInterface $start, ?int $userId = null): Builder { $inWindow = DB::table('artwork_metric_snapshots_hourly as snapshots'); $this->constrainOwner($inWindow, $userId); $inWindow ->where('snapshots.bucket_hour', '>=', $start) ->groupBy('snapshots.artwork_id') ->select('snapshots.artwork_id') ->selectRaw('MAX(snapshots.views_count) as views_latest') ->selectRaw('MIN(snapshots.views_count) as views_min') ->selectRaw('MAX(snapshots.downloads_count) as downloads_latest') ->selectRaw('MIN(snapshots.downloads_count) as downloads_min') ->selectRaw('MAX(snapshots.favourites_count) as favourites_latest') ->selectRaw('MIN(snapshots.favourites_count) as favourites_min') ->selectRaw('MAX(snapshots.comments_count) as comments_latest') ->selectRaw('MIN(snapshots.comments_count) as comments_min') ->selectRaw('MAX(snapshots.shares_count) as shares_latest') ->selectRaw('MIN(snapshots.shares_count) as shares_min'); $baselineFrom = \Carbon\Carbon::parse($start)->subHours(self::BASELINE_LOOKBACK_HOURS); $baseline = DB::table('artwork_metric_snapshots_hourly as snapshots'); $this->constrainOwner($baseline, $userId); $baseline ->where('snapshots.bucket_hour', '<', $start) ->where('snapshots.bucket_hour', '>=', $baselineFrom) ->groupBy('snapshots.artwork_id') ->select('snapshots.artwork_id') ->selectRaw('MAX(snapshots.views_count) as views_baseline') ->selectRaw('MAX(snapshots.downloads_count) as downloads_baseline') ->selectRaw('MAX(snapshots.favourites_count) as favourites_baseline') ->selectRaw('MAX(snapshots.comments_count) as comments_baseline') ->selectRaw('MAX(snapshots.shares_count) as shares_baseline'); $bornSql = $this->quotedTimestamp($start); return DB::query() ->fromSub($inWindow, 'win') ->leftJoinSub($baseline, 'base', 'base.artwork_id', '=', 'win.artwork_id') ->join('artworks', 'artworks.id', '=', 'win.artwork_id') ->select('win.artwork_id') ->selectRaw($this->deltaExpression('views', $bornSql) . ' as views_delta') ->selectRaw($this->deltaExpression('downloads', $bornSql) . ' as downloads_delta') ->selectRaw($this->deltaExpression('favourites', $bornSql) . ' as favourites_delta') ->selectRaw($this->deltaExpression('comments', $bornSql) . ' as comments_delta') ->selectRaw($this->deltaExpression('shares', $bornSql) . ' as shares_delta'); } /** * @return array{oldest_bucket: ?string, newest_bucket: ?string, coverage_days: float, window_complete: bool, requested_days: int} */ public function tableCoverage(int $days = 30): array { $days = max(1, $days); if (! Schema::hasTable('artwork_metric_snapshots_hourly')) { return [ 'oldest_bucket' => null, 'newest_bucket' => null, 'coverage_days' => 0.0, 'window_complete' => false, 'requested_days' => $days, ]; } $bounds = DB::table('artwork_metric_snapshots_hourly') ->selectRaw('MIN(bucket_hour) as oldest_bucket') ->selectRaw('MAX(bucket_hour) as newest_bucket') ->first(); $coverageDays = $this->coverageDays( $bounds->oldest_bucket ?? null, $bounds->newest_bucket ?? null, ); return [ 'oldest_bucket' => $bounds->oldest_bucket ?? null, 'newest_bucket' => $bounds->newest_bucket ?? null, 'coverage_days' => $coverageDays, 'window_complete' => $coverageDays + (1 / 24) >= $days, 'requested_days' => $days, ]; } private function constrainOwner(Builder $query, ?int $userId): void { if ($userId === null) { return; } $query->join('artworks as owner_artworks', 'owner_artworks.id', '=', 'snapshots.artwork_id') ->where('owner_artworks.user_id', $userId) ->whereNull('owner_artworks.deleted_at'); } private function quotedTimestamp(CarbonInterface|\DateTimeInterface $start): string { return DB::getPdo()->quote(\Carbon\Carbon::parse($start)->toDateTimeString()); } private function deltaExpression(string $metric, string $bornSql): string { $latest = "COALESCE(win.{$metric}_latest, 0)"; $min = "COALESCE(win.{$metric}_min, 0)"; $base = "base.{$metric}_baseline"; $bornInWindow = "COALESCE(artworks.published_at, artworks.created_at) >= {$bornSql}"; $observed = $this->clampNonNegative("{$latest} - {$min}"); $fromBaseline = $this->clampNonNegative("{$latest} - {$base}"); return "CASE WHEN {$bornInWindow} THEN {$latest} WHEN {$base} IS NOT NULL THEN {$fromBaseline} ELSE {$observed} END"; } private function clampNonNegative(string $expression): string { if (DB::connection()->getDriverName() === 'sqlite') { return "CASE WHEN ({$expression}) > 0 THEN ({$expression}) ELSE 0 END"; } return "GREATEST({$expression}, 0)"; } /** * @return array */ private function emptyResult(int $days, array $coverage): array { return [ 'views' => 0, 'downloads' => 0, 'favourites' => 0, 'comments' => 0, 'shares' => 0, 'requested_days' => $days, 'table_coverage_days' => $coverage['coverage_days'], 'user_coverage_days' => 0.0, 'window_complete' => $coverage['window_complete'], 'oldest_bucket' => $coverage['oldest_bucket'], 'newest_bucket' => $coverage['newest_bucket'], 'user_oldest_bucket' => null, 'user_newest_bucket' => null, ]; } private function coverageDays(?string $oldest, ?string $newest): float { if ($oldest === null || $newest === null) { return 0.0; } $rangeStart = \Carbon\Carbon::parse($oldest); $rangeEnd = \Carbon\Carbon::parse($newest); if ($rangeEnd->lessThan($rangeStart)) { return 0.0; } return round(abs($rangeStart->diffInMinutes($rangeEnd)) / 1440, 2); } }