> */ protected const TYPES = [ 'pages' => Page::class, 'projects' => Project::class, 'services' => Service::class, 'calls' => Call::class, 'members' => Member::class, ]; public function __construct(public readonly int $days) {} public function from(): CarbonImmutable { return CarbonImmutable::today()->subDays($this->days - 1); } public function previousFrom(): CarbonImmutable { return $this->from()->subDays($this->days); } /** * @return Collection */ public function top(int $limit = 10): Collection { $current = ContentView::query() ->where('day', '>=', $this->from()->toDateString()) ->selectRaw('viewable_type, viewable_id, SUM(count) as total') ->groupBy('viewable_type', 'viewable_id') ->orderByDesc('total') ->limit($limit) ->get(); $previous = ContentView::query() ->whereBetween('day', [$this->previousFrom()->toDateString(), $this->from()->subDay()->toDateString()]) ->where(function ($q) use ($current): void { foreach ($current as $row) { $q->orWhere(fn ($q) => $q->where('viewable_type', $row->viewable_type)->where('viewable_id', $row->viewable_id)); } }) ->selectRaw('viewable_type, viewable_id, SUM(count) as total') ->groupBy('viewable_type', 'viewable_id') ->get() ->keyBy(fn ($row) => $row->viewable_type.':'.$row->viewable_id); return $current ->map(function ($row) use ($previous): ?array { $record = $row->viewable; if (! $record instanceof Model) { return null; } return [ 'type' => array_search($record::class, self::TYPES, true) ?: class_basename($record), 'title' => $record instanceof Member ? $record->name : ContentTypes::title($record), 'url' => self::url($record), 'views' => (int) $row->total, 'previous' => (int) ($previous[$row->viewable_type.':'.$row->viewable_id]->total ?? 0), ]; }) ->filter() ->values(); } /** * Reads per language, now and in the period before. * * @return Collection */ public function byLocale(): Collection { $sum = fn (string $from, string $to) => ContentView::query() ->whereBetween('day', [$from, $to]) ->selectRaw('locale, SUM(count) as total') ->groupBy('locale') ->pluck('total', 'locale'); $now = $sum($this->from()->toDateString(), CarbonImmutable::today()->toDateString()); $before = $sum($this->previousFrom()->toDateString(), $this->from()->subDay()->toDateString()); $total = max(1, (int) $now->sum()); return collect(array_keys((array) config('locales.supported'))) ->mapWithKeys(fn (string $locale) => [$locale => [ 'views' => (int) ($now[$locale] ?? 0), 'previous' => (int) ($before[$locale] ?? 0), 'share' => round(((int) ($now[$locale] ?? 0)) * 100 / $total, 1), ]]); } /** * Day by day, per language, for the chart. * * @return array{labels: array, series: array>} */ public function daily(): array { $rows = ContentView::query() ->where('day', '>=', $this->from()->toDateString()) ->selectRaw('day, locale, SUM(count) as total') ->groupBy('day', 'locale') ->get() ->groupBy(fn ($row) => CarbonImmutable::parse($row->day)->toDateString()); $labels = []; $series = array_fill_keys(array_keys((array) config('locales.supported')), []); for ($day = $this->from(); $day->lte(CarbonImmutable::today()); $day = $day->addDay()) { $labels[] = $day->isoFormat('D MMM'); $today = ($rows[$day->toDateString()] ?? collect())->pluck('total', 'locale'); foreach (array_keys($series) as $locale) { $series[$locale][] = (int) ($today[$locale] ?? 0); } } return ['labels' => $labels, 'series' => $series]; } /** * On the site and read by nobody in the period: the content that is * either hard to find or not worth finding. * * @return Collection */ public function unread(int $limit = 50): Collection { $items = collect(); foreach (self::TYPES as $type => $model) { $read = ContentView::query() ->where('viewable_type', (new $model)->getMorphClass()) ->where('day', '>=', $this->from()->toDateString()) ->distinct() ->pluck('viewable_id'); $model::query() ->when($model === Call::class, fn ($q) => $q->open()) ->when($model === Member::class, fn ($q) => $q->public()) ->unless(in_array($model, [Call::class, Member::class], true), fn ($q) => $q->published()) ->whereNotIn((new $model)->getKeyName(), $read) ->limit($limit) ->get() ->each(fn (Model $record) => $items->push([ 'type' => $type, 'title' => $record instanceof Member ? $record->name : ContentTypes::title($record), 'url' => self::url($record), ])); } return $items->take($limit)->values(); } protected static function url(Model $record): ?string { return match (true) { $record instanceof Call => CallResource::getUrl('edit', ['record' => $record]), $record instanceof Member => MemberResource::getUrl('edit', ['record' => $record]), $record instanceof Page, $record instanceof Project, $record instanceof Service => ReviewTarget::url($record), default => null, }; } }