>}>, * umami: array{configured: bool, error: string|null}, * pages: list, * } */ public function build(bool $fresh = false, ?Carbon $now = null): array { $now ??= Carbon::now(); $start = Carbon::parse((string) config('growth.period.start'))->startOfDay(); $review = Carbon::parse((string) config('growth.period.review'))->startOfDay(); $end = Carbon::parse((string) config('growth.period.end'))->endOfDay(); $elapsedShare = $now->lessThanOrEqualTo($start) ? 0.0 : min(1.0, $start->diffInSeconds($now) / max(1, $start->diffInSeconds($end))); $ended = $now->greaterThan($end); $umami = $this->searchViews($fresh, $now); return [ 'period' => ['start' => $start, 'review' => $review, 'end' => $end, 'elapsed_share' => $elapsedShare], 'blocks' => [ [ 'key' => 'block1', 'title' => 'Block 1 — Konversion aus dem Bestand', 'consequence' => 'Wenn verfehlt: Block 1 einfrieren, Gewicht auf Block 3.', 'criteria' => [ $this->criterion('claims', 'Firmen mit eingelöstem Verwaltungslink', $this->claimedCompanies($start), (int) config('growth.block1.claims_min'), $elapsedShare, $ended, cumulative: true, note: $this->claimsWithoutCompanyNote($start)), $this->criterion('bookings', 'Erstbuchungen über Mail oder Claim', $this->companiesWith(ConversionEventType::FirstBooking, $start), (int) config('growth.block1.bookings_min'), $elapsedShare, $ended, cumulative: true), $this->criterion('reject_clicks', 'Klicks aus der Ablehnungsmail', $this->rejectMailClicks($start), (int) config('growth.block1.reject_clicks_min'), $elapsedShare, $ended, cumulative: true), ], ], [ 'key' => 'block2', 'title' => 'Block 2 — Organische Sichtbarkeit', 'consequence' => 'Wenn verfehlt: Marketing-Teil nicht weiter ausbauen, nur 2.1 bis 2.3 behalten.', 'criteria' => [ $this->criterion('search_views', 'Aufrufe der Transparenzseiten aus der Suche (30 Tage)', $umami['total'], (int) config('growth.block2.search_views_min'), $elapsedShare, $ended, cumulative: false, note: $umami['error'] ?? 'Gewertet wird der Monat '.Carbon::parse((string) config('growth.block2.evaluation_month').'-01')->locale('de')->isoFormat('MMMM YYYY').'.'), ], ], [ 'key' => 'block3', 'title' => 'Block 3 — Reichweite', 'consequence' => 'Wenn verfehlt: RSS ist kein Verkaufsargument; der Digest muss sich eigenständig begründen.', 'criteria' => [ $this->criterion('feed_subscribers', 'Gemeldete Feed-Abonnenten (30 Tage)', $this->feedSubscribers(), (int) config('growth.block3.feed_subscribers_min'), $elapsedShare, $ended, cumulative: false, note: 'Nur Reader-Dienste, die Abonnenten im User-Agent melden (Feedly, Inoreader …).'), ], ], ], 'umami' => ['configured' => $this->umami->isConfigured(), 'error' => $umami['error']], 'pages' => $umami['pages'], ]; } public static function statusLabel(string $status): string { return match ($status) { 'reached' => 'erreicht', 'on_track' => 'auf Kurs', 'behind' => 'hinter Plan', 'open' => 'noch nicht erreicht', 'missed' => 'verfehlt', default => 'keine Daten', }; } /** * Flux-Badge-Farbe je Status. */ public static function statusColor(string $status): string { return match ($status) { 'reached', 'on_track' => 'green', 'behind', 'open' => 'amber', 'missed' => 'red', default => 'zinc', }; } /** * @return array */ private function criterion(string $key, string $label, ?int $actual, int $threshold, float $elapsedShare, bool $ended, bool $cumulative, ?string $note = null): array { $expected = $cumulative ? (int) ceil($threshold * $elapsedShare) : null; $status = match (true) { $actual === null => 'unknown', $actual >= $threshold => 'reached', $ended => 'missed', $cumulative && $actual >= $expected => 'on_track', $cumulative => 'behind', default => 'open', }; return [ 'key' => $key, 'label' => $label, 'actual' => $actual, 'threshold' => $threshold, 'expected' => $expected, 'status' => $status, 'note' => $note, ]; } private function claimedCompanies(Carbon $start): int { return $this->companiesWith(ConversionEventType::MagicLinkConsumed, $start); } private function companiesWith(ConversionEventType $type, Carbon $start): int { return ConversionEvent::query() ->where('type', $type->value) ->whereIn('source', array_map(static fn (ConversionSource $source): string => $source->value, self::CLAIM_SOURCES)) ->whereNotNull('company_id') ->where('created_at', '>=', $start) ->distinct() ->count('company_id'); } /** * Einlösungen vor v1.10 tragen keine Firma (Roadmap 6.16) und fehlen * deshalb in der Zählung — der Hinweis macht die Lücke sichtbar. */ private function claimsWithoutCompanyNote(Carbon $start): ?string { $count = ConversionEvent::query() ->where('type', ConversionEventType::MagicLinkConsumed->value) ->whereIn('source', array_map(static fn (ConversionSource $source): string => $source->value, self::CLAIM_SOURCES)) ->whereNull('company_id') ->where('created_at', '>=', $start) ->count(); return $count > 0 ? sprintf('Dazu %d %s ohne Firmenbezug (vor der Korrektur am 13.10.2026 erfasst).', $count, $count === 1 ? 'Einlösung' : 'Einlösungen') : null; } private function rejectMailClicks(Carbon $start): int { return ConversionEvent::query() ->where('type', ConversionEventType::MailLinkClicked->value) ->where('source', ConversionSource::MailReject->value) ->where('created_at', '>=', $start) ->count(); } private function feedSubscribers(): int { return (int) collect($this->feeds->byReader(30))->sum(static fn (array $reader): int => (int) $reader['subscribers']); } /** * Aufrufe der Transparenzseiten der letzten 30 Tage, gesamt und aus der * Suche, je Marke und Seite. Deutsch und Englisch zusammen * (Filter „enthält“ auf den Slug). * * @return array{total: int|null, error: string|null, pages: list} */ private function searchViews(bool $fresh, Carbon $now): array { if (! $this->umami->isConfigured()) { return ['total' => null, 'error' => 'Umami-API nicht verbunden (UMAMI_API_* in der .env).', 'pages' => []]; } $key = 'growth.block2.'.$now->toDateString(); if ($fresh) { Cache::forget($key); } try { /** @var list $pages */ $pages = Cache::remember($key, now()->addMinutes((int) config('growth.cache_minutes', 360)), function () use ($now): array { $from = $now->copy()->subDays(30); $engines = (array) config('growth.block2.search_referrers', []); $rows = []; foreach ((array) config('growth.block2.brands', []) as $brand) { $websiteId = $this->umami->websiteId((string) $brand); if ($websiteId === null) { continue; } foreach ((array) config('growth.block2.pages', []) as $page) { $filter = ['path' => 'c.'.$page]; $views = $this->umami->stats($websiteId, $from, $now, $filter)['pageviews']; $search = 0; if ($views > 0) { foreach ($this->umami->metrics($websiteId, 'referrer', $from, $now, $filter, 100) as $referrer) { if (self::isSearchReferrer($referrer['x'], $engines)) { $search += $referrer['y']; } } } $rows[] = ['brand' => (string) $brand, 'page' => (string) $page, 'views' => $views, 'search_views' => $search]; } } return $rows; }); } catch (UmamiUnavailable $e) { return ['total' => null, 'error' => $e->getMessage(), 'pages' => []]; } return ['total' => (int) array_sum(array_column($pages, 'search_views')), 'error' => null, 'pages' => $pages]; } /** * @param array $engines */ private static function isSearchReferrer(string $referrer, array $engines): bool { $referrer = mb_strtolower($referrer); foreach ($engines as $engine) { if ($engine !== '' && str_contains($referrer, mb_strtolower((string) $engine))) { return true; } } return false; } }