*/ private const Buckets = [0, 25, 50, 100, 200, 500, 1000]; /** * Alterskohorten in Tagen seit Veröffentlichung (Untergrenze je Klasse). * * @var list */ private const Cohorts = [0, 7, 14, 21, 28, 56, 91]; /** * @return array{ * from: string, * to: string, * stichtag: string, * portals: list}}>, cohorts: list}>, * } */ public function build(Carbon $from, ?Carbon $to = null): array { $reference = Carbon::now(); $to ??= $reference->copy(); $portals = []; foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) { $segments = []; foreach (['gesamt' => null, 'seit Relaunch' => true, 'Altbestand' => false] as $label => $onlyNew) { $hits = $this->hits($portal, $from, $to, $onlyNew); if ($hits === [] && $label !== 'gesamt') { continue; } $segments[] = ['segment' => $label, 'stats' => $this->describe($hits)]; } $portals[] = [ 'portal' => $portal->value, 'label' => $portal->label(), 'segments' => $segments, 'cohorts' => $this->cohorts($portal, $from, $to, $reference), ]; } return [ 'from' => $from->toDateString(), 'to' => $to->toDateString(), 'stichtag' => $reference->toDateString(), 'portals' => $portals, ]; } /** * Aufrufe nach Alter der Meldung. * * Der Grund, warum es diese Aufschlüsselung gibt: `hits` ist ein * mitlaufender Gesamtzähler, und die Aufrufe wachsen nach der * Veröffentlichung über Wochen weiter. Eine Kennzahl über ein * Drei-Monats-Fenster misst deshalb nicht, wie viele Aufrufe eine Meldung * bekommt, sondern wie viel eine zufällig alte Meldung bisher gesammelt * hat — und dieser Wert verschiebt sich mit der Altersmischung des * Fensters. Zwei Messungen zu verschiedenen Zeitpunkten wären damit nicht * vergleichbar, und genau das braucht der Basiswert. * * Vergleichbar ist erst die einzelne Kohorte: „Median der Meldungen im * Alter von 28 bis 55 Tagen" lässt sich in einem halben Jahr identisch * erheben. DIESE Zeile ist der Basiswert, nicht die Gesamtzahl. * * Nur Meldungen seit dem Relaunch — die Zähler des Altbestands stammen aus * dem Altsystem und haben keine hier nachvollziehbare Vorgeschichte. * * Das Alter zählt ab dem Stichtag der Erhebung (heute), nicht ab `$to`: * `hits` steht immer auf dem Stand von heute, also muss auch das Alter * von heute aus gerechnet werden. Mit einem `$to` in der Vergangenheit * fallen die jungen Kohorten schlicht leer aus — statt Meldungen, die in * Wahrheit Wochen älter sind, als „0–6 Tage" auszuweisen. * * @return list */ private function cohorts(Portal $portal, Carbon $from, Carbon $to, Carbon $reference): array { $bounds = self::Cohorts; $result = []; foreach ($bounds as $i => $lowerDays) { $upperDays = $bounds[$i + 1] ?? null; // Älter heißt: früher veröffentlicht. Die Altersgrenzen drehen sich // deshalb zu Datumsgrenzen um — und bleiben im Fenster from..to. $newest = $reference->copy()->subDays($lowerDays)->min($to); $oldest = ($upperDays === null ? $from : $reference->copy()->subDays($upperDays - 1))->max($from); if ($oldest->greaterThan($newest)) { continue; } $hits = $this->hits($portal, $oldest, $newest, true); $count = count($hits); $result[] = [ 'alter' => $upperDays === null ? "{$lowerDays}+ Tage" : "{$lowerDays}–".($upperDays - 1).' Tage', 'anzahl' => $count, 'median' => $count > 0 ? $this->percentile($hits, 0.50) : null, 'mittelwert' => $count > 0 ? round(array_sum($hits) / $count, 1) : null, ]; } return $result; } /** * Aufrufzahlen der im Zeitraum veröffentlichten Meldungen. * * Eine Meldung mit `portal = both` erscheint auf beiden Portalen und zählt * deshalb bei beiden mit — der Zähler ist einer, die Sichtbarkeit doppelt. * * @return list */ private function hits(Portal $portal, Carbon $from, Carbon $to, ?bool $onlyNew): array { return PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('portal', [$portal->value, Portal::Both->value]) ->where('status', PressReleaseStatus::Published->value) ->whereNull('gone_at') ->whereBetween('published_at', [$from, $to]) ->when($onlyNew === true, fn (Builder $query) => $query->whereNull('legacy_portal')) ->when($onlyNew === false, fn (Builder $query) => $query->whereNotNull('legacy_portal')) ->orderBy('hits') ->pluck('hits') ->map(static fn ($hits): int => (int) $hits) ->all(); } /** * @param list $hits aufsteigend sortiert * @return array{anzahl: int, min?: int, p10?: int, p25?: int, median?: int, p75?: int, p90?: int, max?: int, mittelwert?: float, verteilung?: array} */ private function describe(array $hits): array { $count = count($hits); if ($count === 0) { return ['anzahl' => 0]; } return [ 'anzahl' => $count, 'min' => $hits[0], 'p10' => $this->percentile($hits, 0.10), 'p25' => $this->percentile($hits, 0.25), 'median' => $this->percentile($hits, 0.50), 'p75' => $this->percentile($hits, 0.75), 'p90' => $this->percentile($hits, 0.90), 'max' => $hits[$count - 1], 'mittelwert' => round(array_sum($hits) / $count, 1), 'verteilung' => $this->histogram($hits), ]; } /** * Perzentil nach der „nearest rank"-Methode: Der zurückgegebene Wert ist * immer eine tatsächlich gemessene Aufrufzahl, kein interpolierter * Zwischenwert. Für eine Einordnung („die Hälfte der Meldungen liegt * darunter") ist das die ehrlichere Angabe. * * @param list $sorted */ private function percentile(array $sorted, float $fraction): int { $rank = (int) ceil($fraction * count($sorted)); return $sorted[max(0, $rank - 1)]; } /** * @param list $hits * @return array */ private function histogram(array $hits): array { $bounds = self::Buckets; $result = []; foreach ($bounds as $i => $lower) { $upper = $bounds[$i + 1] ?? null; $label = $upper === null ? "{$lower}+" : "{$lower}–".($upper - 1); $result[$label] = count(array_filter( $hits, static fn (int $h): bool => $h >= $lower && ($upper === null || $h < $upper), )); } return $result; } }