withoutGlobalScopes()->where('company_id', $company->id); $row = $base->clone() ->selectRaw('count(*) as releases') ->selectRaw('sum(case when status = ? then 1 else 0 end) as published', [PressReleaseStatus::Published->value]) ->selectRaw('coalesce(sum(hits), 0) as views_total') ->selectRaw('max(published_at) as last_published_at') ->toBase() ->first(); $recent = PressReleaseEvent::query() ->where('type', PressReleaseEventType::View->value) ->where('created_at', '>=', now()->subDays($days)) ->whereIn('press_release_id', $base->clone()->select('id')) ->count(); return [ 'releases' => (int) ($row->releases ?? 0), 'published' => (int) ($row->published ?? 0), 'views_total' => (int) ($row->views_total ?? 0), 'views_recent' => $recent, 'last_published_at' => ($row->last_published_at ?? null) ? Carbon::parse($row->last_published_at) : null, ]; } /** * Aufrufe der letzten N Tage je Meldung, für eine Liste. * * @param list $pressReleaseIds * @return array */ public function recentViewsFor(array $pressReleaseIds, int $days = 30): array { if ($pressReleaseIds === []) { return []; } return PressReleaseEvent::query() ->where('type', PressReleaseEventType::View->value) ->where('created_at', '>=', now()->subDays($days)) ->whereIn('press_release_id', $pressReleaseIds) ->selectRaw('press_release_id, count(*) as views') ->groupBy('press_release_id') ->pluck('views', 'press_release_id') ->map(fn ($views): int => (int) $views) ->all(); } /** * Tagesreihe der Aufrufe, lückenlos über die letzten N Tage (heute * eingeschlossen). Tage vor Beginn der Ereignisaufzeichnung oder vor der * Veröffentlichung sind `null`, nicht 0 — es gab dort keine Messung. * * @return list */ public function dailyViews(PressRelease $pressRelease, int $days = 30): array { $days = max(1, min($days, (int) config('press_releases.events.retention_days', 395))); $start = now()->startOfDay()->subDays($days - 1); $counts = $pressRelease->events() ->where('type', PressReleaseEventType::View->value) ->where('created_at', '>=', $start) ->selectRaw('date(created_at) as day, count(*) as views') ->groupBy('day') ->pluck('views', 'day'); $measuredFrom = $this->measurementStart($pressRelease); $series = []; for ($i = 0; $i < $days; $i++) { $day = $start->copy()->addDays($i); $series[] = [ 'date' => $day, 'views' => $measuredFrom !== null && $day->lt($measuredFrom->copy()->startOfDay()) ? null : (int) ($counts[$day->toDateString()] ?? 0), ]; } return $series; } /** * Einzelwerte einer Meldung für das Panel „Sichtbarkeit". * * @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array, shares_total: int, in_sitemap_since: Carbon|null, search_url: string|null} */ public function releaseSummary(PressRelease $pressRelease): array { $views = $pressRelease->events() ->where('type', PressReleaseEventType::View->value) ->selectRaw('min(created_at) as first_view, max(created_at) as last_view') ->toBase() ->first(); $shares = $pressRelease->events() ->where('type', PressReleaseEventType::Share->value) ->selectRaw('channel, count(*) as clicks') ->groupBy('channel') ->pluck('clicks', 'channel') ->mapWithKeys(fn ($clicks, $channel): array => [ PressReleaseShareChannel::tryFrom((string) $channel)?->label() ?? (string) $channel => (int) $clicks, ]) ->sortDesc(); $links = $pressRelease->isVisibleInArchive() ? $pressRelease->publicPortalLinks() : []; $firstUrl = $links[0]['url'] ?? null; return [ 'hits' => (int) $pressRelease->hits, 'first_view_at' => $views?->first_view ? Carbon::parse($views->first_view) : null, 'last_view_at' => $views?->last_view ? Carbon::parse($views->last_view) : null, 'shares' => $shares->all(), 'shares_total' => (int) $shares->sum(), // Die Sitemap listet jede im Archiv sichtbare Meldung — ab dem // Moment der Veröffentlichung. Das ist eine Aussage über uns, nicht // über Google. 'in_sitemap_since' => $links !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null, 'search_url' => $firstUrl !== null ? 'https://www.google.com/search?q='.rawurlencode('site:'.preg_replace('#^https?://#', '', $firstUrl)) : null, ]; } /** * Ab wann es für diese Meldung überhaupt Tageswerte geben kann: der * spätere von Veröffentlichung und Beginn der Ereignisaufzeichnung. */ private function measurementStart(PressRelease $pressRelease): ?Carbon { $candidates = collect([ $pressRelease->published_at, $this->eventsSince(), ])->filter(); return $candidates->isEmpty() ? null : $candidates->max(); } private function eventsSince(): ?Carbon { $since = config('press_releases.events.recorded_since'); return $since ? Carbon::parse((string) $since) : null; } /** * Umschaltbare Zeiträume der Verlaufsgrafik. * * @return Collection */ public static function ranges(): Collection { return collect([30, 90]); } }