admin: Reports ▸ Wachstum mit den Abbruchbedingungen, Umami nur lesend angebunden

Roadmap 6.1/6.16: Block 1 bis 3 mit Ist, zeitanteiligem Soll, Schwelle und
Status, Trichter seit 17.09., Transparenzseiten aus Umami. Neue Befehle
growth:report und umami:report. Zugang über UMAMI_API_* (View-only-Benutzer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-13 14:55:29 +00:00
parent e8cf684f02
commit b4870f98bb
12 changed files with 1098 additions and 0 deletions

View file

@ -278,6 +278,14 @@ UMAMI_ENABLED=false
# erst nach Ergänzung der Datenschutzerklärung einschalten.
UMAMI_PORTAL_ENABLED=false
UMAMI_IDENTIFY_USERS=false
# Nur lesender API-Zugang für Admin ▸ Reports ▸ Wachstum und `umami:report`
# (Umami-Benutzer mit der Rolle „View only“, Mitglied im Team mit den drei
# Websites). Ohne diese Werte zeigt die Seite die Umami-Kennzahlen als
# „nicht verbunden“.
UMAMI_API_URL=
UMAMI_API_USERNAME=
UMAMI_API_PASSWORD=
UMAMI_API_TEAM_ID=
# Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB,
# Meldeverfahren, Richtlinien) — auf allen drei Domains identisch.

View file

@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use App\Services\Growth\GrowthReport;
use Illuminate\Console\Command;
/**
* Stand der Abbruchbedingungen für Block 1 bis 3 (Roadmap 6.1) — dieselben
* Zahlen wie Admin ▸ Reports ▸ Wachstum.
*/
class ReportGrowth extends Command
{
protected $signature = 'growth:report
{--fresh : Umami-Werte neu abfragen statt aus dem Zwischenspeicher}
{--json : Ausgabe als JSON statt als Tabelle}';
protected $description = 'Abbruchbedingungen der Wachstumsblöcke: Ist, Schwelle, Status.';
public function handle(GrowthReport $report): int
{
$data = $report->build((bool) $this->option('fresh'));
if ($this->option('json')) {
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$period = $data['period'];
$this->info(sprintf(
'Wachstum — Zeitraum %s bis %s, Zwischenblick %s, %d %% der Zeit vergangen',
$period['start']->format('d.m.Y'),
$period['end']->format('d.m.Y'),
$period['review']->format('d.m.Y'),
(int) round($period['elapsed_share'] * 100),
));
foreach ($data['blocks'] as $block) {
$this->newLine();
$this->line("<comment>{$block['title']}</comment>");
$this->table(
['Bedingung', 'Ist', 'Soll heute', 'Schwelle', 'Status'],
array_map(static fn (array $criterion): array => [
$criterion['label'],
$criterion['actual'] ?? '–',
$criterion['expected'] ?? '–',
$criterion['threshold'],
GrowthReport::statusLabel($criterion['status']),
], $block['criteria']),
);
foreach ($block['criteria'] as $criterion) {
if ($criterion['note'] !== null) {
$this->line(' '.$criterion['note']);
}
}
$this->line(' '.$block['consequence']);
}
if ($data['pages'] !== []) {
$this->newLine();
$this->line('<comment>Transparenzseiten, letzte 30 Tage (Umami)</comment>');
$this->table(
['Portal', 'Seite', 'Aufrufe', 'aus der Suche'],
array_map(static fn (array $row): array => [$row['brand'], $row['page'], $row['views'], $row['search_views']], $data['pages']),
);
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use App\Services\Analytics\UmamiClient;
use App\Services\Analytics\UmamiUnavailable;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Nur lesende Abfrage der Umami-Webanalyse (Roadmap 6.16): Kennzahlen je
* Website, dazu eine Rangliste (Seiten, Herkunft, Kanäle …), optional auf
* Pfade gefiltert. Zugangsdaten aus `UMAMI_API_*`, werden nie ausgegeben.
*/
class ReportUmami extends Command
{
protected $signature = 'umami:report
{--from= : Startdatum (Standard: vor 30 Tagen)}
{--to= : Enddatum (Standard: jetzt)}
{--site=* : pressekonto, presseecho, businessportal24 (Standard: alle)}
{--path= : nur Pfade, die diesen Text enthalten, z. B. gute-pressemitteilung}
{--metric=channel : Rangliste nach path, referrer, channel, country, device, browser, event}
{--limit=10 : Zeilen der Rangliste}
{--json : Ausgabe als JSON statt als Tabelle}';
protected $description = 'Umami-Kennzahlen je Website (nur lesend).';
private const array METRICS = ['path', 'referrer', 'channel', 'country', 'device', 'browser', 'os', 'event', 'title', 'entry', 'exit'];
public function handle(UmamiClient $umami): int
{
if (! $umami->isConfigured()) {
$this->error('Umami-API nicht konfiguriert: UMAMI_API_URL, UMAMI_API_USERNAME und UMAMI_API_PASSWORD setzen.');
return self::FAILURE;
}
$metric = (string) $this->option('metric');
if (! in_array($metric, self::METRICS, true)) {
$this->error('Unbekannte Rangliste. Möglich: '.implode(', ', self::METRICS).'.');
return self::INVALID;
}
$to = $this->option('to') !== null ? Carbon::parse((string) $this->option('to'))->endOfDay() : Carbon::now();
$from = $this->option('from') !== null ? Carbon::parse((string) $this->option('from'))->startOfDay() : $to->copy()->subDays(30);
$sites = (array) $this->option('site') ?: UmamiClient::BRANDS;
$filters = filled($this->option('path')) ? ['path' => 'c.'.$this->option('path')] : [];
$limit = max(1, min(500, (int) $this->option('limit')));
$result = [];
try {
foreach ($sites as $site) {
$websiteId = $umami->websiteId((string) $site);
if ($websiteId === null) {
$this->warn("Keine Umami-Website für „{$site}“ in config/domains.php.");
continue;
}
$result[$site] = [
'stats' => $umami->stats($websiteId, $from, $to, $filters),
$metric => $umami->metrics($websiteId, $metric, $from, $to, $filters, $limit),
];
}
} catch (UmamiUnavailable $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
if ($this->option('json')) {
$this->line((string) json_encode([
'from' => $from->toIso8601String(),
'to' => $to->toIso8601String(),
'filters' => $filters,
'sites' => $result,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info(sprintf('Umami — %s bis %s%s', $from->format('d.m.Y'), $to->format('d.m.Y'), $filters === [] ? '' : ', Pfad enthält „'.$this->option('path').'“'));
foreach ($result as $site => $data) {
$this->newLine();
$this->line("<comment>{$site}</comment>");
$this->table(
['Aufrufe', 'Besucher', 'Besuche', 'Absprünge'],
[[$data['stats']['pageviews'], $data['stats']['visitors'], $data['stats']['visits'], $data['stats']['bounces']]],
);
$this->table(
[ucfirst($metric), 'Anzahl'],
array_map(static fn (array $row): array => [$row['x'], $row['y']], $data[$metric]),
);
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,166 @@
<?php
namespace App\Services\Analytics;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Nur lesender Client für die selbst gehostete Umami-Instanz (Roadmap 6.16).
*
* Anmeldung mit einem Umami-Benutzer der Rolle „View only“; das Token wird
* zwischengespeichert und bei einer 401 einmal erneuert. Die Websites hängen
* am Team, nicht am Benutzer — ihre IDs stehen je Marke in
* `config/domains.php`.
*
* Eigenheiten der Umami-v3-API: Seiten heißen `path` (nicht `url`), ein
* Filter `path=c.<Teil>` sucht nach „enthält“, Zeiten in Millisekunden.
*/
class UmamiClient
{
private const string TOKEN_CACHE_KEY = 'umami.api-token';
/** Marken mit eigener Umami-Website. */
public const array BRANDS = ['pressekonto', 'presseecho', 'businessportal24'];
public function isConfigured(): bool
{
return filled(config('services.umami.api_url'))
&& filled(config('services.umami.api_username'))
&& filled(config('services.umami.api_password'));
}
public function websiteId(string $brand): ?string
{
$id = config('domains.domains.'.$brand.'.brand.umami_website_id');
return is_string($id) && $id !== '' ? $id : null;
}
/**
* Kennzahlen eines Zeitraums, optional gefiltert (z. B. `['path' => 'c.preise']`).
*
* @param array<string, string> $filters
* @return array{pageviews: int, visitors: int, visits: int, bounces: int, totaltime: int}
*/
public function stats(string $websiteId, CarbonInterface $from, CarbonInterface $to, array $filters = []): array
{
$data = $this->get('/api/websites/'.$websiteId.'/stats', $this->range($from, $to) + $filters);
return [
'pageviews' => (int) ($data['pageviews'] ?? 0),
'visitors' => (int) ($data['visitors'] ?? 0),
'visits' => (int) ($data['visits'] ?? 0),
'bounces' => (int) ($data['bounces'] ?? 0),
'totaltime' => (int) ($data['totaltime'] ?? 0),
];
}
/**
* Rangliste einer Dimension: `path`, `referrer`, `channel`, `country`,
* `browser`, `os`, `device`, `event` …
*
* @param array<string, string> $filters
* @return list<array{x: string, y: int}>
*/
public function metrics(string $websiteId, string $type, CarbonInterface $from, CarbonInterface $to, array $filters = [], int $limit = 10): array
{
$data = $this->get('/api/websites/'.$websiteId.'/metrics', $this->range($from, $to) + $filters + [
'type' => $type,
'limit' => $limit,
]);
return array_values(array_map(
static fn (array $row): array => ['x' => (string) ($row['x'] ?? ''), 'y' => (int) ($row['y'] ?? 0)],
array_filter($data, 'is_array'),
));
}
/**
* @param array<string, scalar> $query
* @return array<mixed>
*/
private function get(string $path, array $query): array
{
$response = $this->send($path, $query, $this->token());
if ($response->status() === 401) {
Cache::forget(self::TOKEN_CACHE_KEY);
$response = $this->send($path, $query, $this->token());
}
if (! $response->successful()) {
throw new UmamiUnavailable(sprintf('Umami antwortet mit %d auf %s.', $response->status(), $path));
}
$data = $response->json();
return is_array($data) ? $data : [];
}
/**
* @param array<string, scalar> $query
*/
private function send(string $path, array $query, string $token): Response
{
try {
return Http::baseUrl($this->baseUrl())
->withToken($token)
->acceptJson()
->connectTimeout(5)
->timeout((int) config('services.umami.api_timeout_seconds', 15))
->get($path, $query);
} catch (ConnectionException $e) {
throw new UmamiUnavailable('Umami nicht erreichbar.', previous: $e);
}
}
private function token(): string
{
if (! $this->isConfigured()) {
throw new UmamiUnavailable('Umami-API nicht konfiguriert (UMAMI_API_URL, UMAMI_API_USERNAME, UMAMI_API_PASSWORD).');
}
return Cache::remember(self::TOKEN_CACHE_KEY, now()->addHours(12), function (): string {
try {
$response = Http::baseUrl($this->baseUrl())
->acceptJson()
->connectTimeout(5)
->timeout((int) config('services.umami.api_timeout_seconds', 15))
->post('/api/auth/login', [
'username' => (string) config('services.umami.api_username'),
'password' => (string) config('services.umami.api_password'),
]);
} catch (ConnectionException $e) {
throw new UmamiUnavailable('Umami nicht erreichbar.', previous: $e);
}
$token = $response->json('token');
if (! $response->successful() || ! is_string($token) || $token === '') {
throw new UmamiUnavailable(sprintf('Umami-Anmeldung abgelehnt (%d).', $response->status()));
}
return $token;
});
}
private function baseUrl(): string
{
return rtrim((string) config('services.umami.api_url'), '/');
}
/**
* @return array{startAt: int, endAt: int}
*/
private function range(CarbonInterface $from, CarbonInterface $to): array
{
return [
'startAt' => $from->getTimestampMs(),
'endAt' => $to->getTimestampMs(),
];
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Services\Analytics;
use RuntimeException;
/**
* Umami war nicht erreichbar oder hat die Anfrage abgelehnt. Auswertungen
* fangen das ab und zeigen „nicht verbunden“ statt abzubrechen.
*/
class UmamiUnavailable extends RuntimeException {}

View file

@ -0,0 +1,271 @@
<?php
namespace App\Services\Growth;
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Models\ConversionEvent;
use App\Services\Analytics\UmamiClient;
use App\Services\Analytics\UmamiUnavailable;
use App\Services\PressRelease\FeedFetchReport;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* Stand der Abbruchbedingungen für Block 1 bis 3 (Roadmap 6.1, 6.16).
*
* Je Bedingung: Ist-Wert, Schwelle, Status. Für Bedingungen, die über den
* Zeitraum hinweg zusammenkommen (Block 1), zusätzlich „auf Kurs“: Liegt der
* Ist-Wert mindestens beim zeitanteiligen Soll? Block 2 und 3 sind
* Momentaufnahmen der letzten 30 Tage und haben kein Soll unterwegs.
*
* Quellen: eigene Ereignisdaten (Trichter, Feed-Abrufe) und Umami. Fällt
* Umami aus, bleibt der Rest stehen; die Umami-Werte zeigen dann den Grund.
*/
class GrowthReport
{
/** Herkünfte, die als „über Mail oder Claim“ zählen. */
private const array CLAIM_SOURCES = [
ConversionSource::MailPublish,
ConversionSource::MailReject,
ConversionSource::MailReview,
ConversionSource::ProfileClaim,
];
public function __construct(
private readonly UmamiClient $umami,
private readonly FeedFetchReport $feeds,
) {}
/**
* @return array{
* period: array{start: Carbon, review: Carbon, end: Carbon, elapsed_share: float},
* blocks: list<array{key: string, title: string, consequence: string, criteria: list<array<string, mixed>>}>,
* umami: array{configured: bool, error: string|null},
* pages: list<array{brand: string, page: string, views: int, search_views: int}>,
* }
*/
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<string, mixed>
*/
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<array{brand: string, page: string, views: int, search_views: int}>}
*/
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<array{brand: string, page: string, views: int, search_views: int}> $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<int, mixed> $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;
}
}

59
config/growth.php Normal file
View file

@ -0,0 +1,59 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Abbruchbedingungen der Wachstumsblöcke (Roadmap 6.1, 13.10.2026)
|--------------------------------------------------------------------------
|
| Grundlage für Admin ▸ Reports ▸ Wachstum und `growth:report`. Ein
| gemeinsamer Zeitraum für alle Blöcke, ein Zwischenblick, danach Audit im
| Quartal. Die Schwellen sind Entscheidungen des Betreibers — geändert
| werden sie hier und in der Roadmap, nicht nur an einer Stelle.
|
*/
'period' => [
'start' => '2026-09-17',
'review' => '2026-12-17',
'end' => '2027-03-17',
],
// Block 1 — Konversion aus dem Bestand. Gezählt werden eindeutige Firmen.
'block1' => [
'claims_min' => 30,
'bookings_min' => 3,
'reject_clicks_min' => 10,
],
// Block 2 — Organische Sichtbarkeit: Aufrufe der Transparenzseiten (2.4)
// aus der Suche, gemessen in Umami über die letzten 30 Tage. Gewertet
// wird der Februar 2027.
'block2' => [
'search_views_min' => 100,
'evaluation_month' => '2027-02',
'pages' => [
'was-kostet-eine-pressemitteilung',
'pressemitteilung-veroeffentlichen-ohne-abo',
'pressemitteilung-loeschen-oder-aendern-lassen',
'pressemitteilung-kostenlos-veroeffentlichen',
],
'brands' => ['presseecho', 'businessportal24'],
// Verweisende Domains, die als Suche zählen (enthält).
'search_referrers' => [
'google.', 'googlequicksearchbox', 'bing.', 'duckduckgo.', 'ecosia.', 'yahoo.',
'search.brave.', 'qwant.', 'startpage.', 'yandex.', 'web.de', 'gmx.', 't-online.',
],
],
// Block 3 — Reichweite: Abonnenten, die Reader-Dienste im User-Agent
// melden (Feedly, Inoreader …), über alle Feeds, letzte 30 Tage.
'block3' => [
'feed_subscribers_min' => 25,
],
// Umami-Abfragen werden so lange zwischengespeichert.
'cache_minutes' => (int) env('GROWTH_REPORT_CACHE_MINUTES', 360),
];

View file

@ -63,6 +63,16 @@ return [
// Ergänzung der Datenschutzerklärung einschalten.
'portal_enabled' => env('UMAMI_PORTAL_ENABLED', false),
'identify_users' => env('UMAMI_IDENTIFY_USERS', false),
// Nur lesender API-Zugang für Auswertungen (Roadmap 6.16): ein
// Umami-Benutzer mit der Rolle „View only“, die Websites hängen am
// Team. Selbst gehostetes Umami kennt keine API-Schlüssel, nur
// Login und Token.
'api_url' => env('UMAMI_API_URL'),
'api_username' => env('UMAMI_API_USERNAME'),
'api_password' => env('UMAMI_API_PASSWORD'),
'api_team_id' => env('UMAMI_API_TEAM_ID'),
'api_timeout_seconds' => (int) env('UMAMI_API_TIMEOUT', 15),
],
/*

View file

@ -230,6 +230,10 @@
{{-- Reports --}}
<flux:navlist.group :heading="__('Reports')" class="grid mb-4">
<flux:navlist.item icon="arrow-trending-up" :href="route('admin.reports.growth')"
:current="request()->routeIs('admin.reports.growth')" wire:navigate>
{{ __('Wachstum') }}
</flux:navlist.item>
<flux:navlist.item icon="chart-bar-square" :href="route('admin.reports.slow-requests')"
:current="request()->routeIs('admin.reports.slow-requests')" wire:navigate>
{{ __('Performance') }}

View file

@ -0,0 +1,163 @@
<?php
use App\Services\Conversion\ConversionFunnelReport;
use App\Services\Growth\GrowthReport;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Admin ▸ Reports ▸ Wachstum (Roadmap 6.1, 6.16).
*
* Die Abbruchbedingungen für Block 1 bis 3 mit Ist, Soll und Status, dazu
* der Trichter seit Beginn des Zeitraums und die Transparenzseiten aus
* Umami. Eigene Ereignisdaten sind immer aktuell; Umami wird
* zwischengespeichert und lässt sich hier neu abfragen.
*/
new #[Layout('components.layouts.app'), Title('Wachstum')] class extends Component
{
public bool $fresh = false;
public function refreshUmami(): void
{
$this->fresh = true;
}
public function with(): array
{
$report = app(GrowthReport::class)->build($this->fresh);
$this->fresh = false;
return [
'report' => $report,
'funnel' => app(ConversionFunnelReport::class)->build($report['period']['start']),
];
}
}; ?>
<div class="space-y-8">
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Reports')"
:title="__('Wachstum')"
:description="__('Abbruchbedingungen der Wachstumsblöcke (Roadmap 6.1): Ist-Wert, zeitanteiliges Soll und Schwelle. Block 1 sammelt über den Zeitraum, Block 2 und 3 sind Momentaufnahmen der letzten 30 Tage. Darunter der Trichter seit Beginn des Zeitraums und die Transparenzseiten aus Umami.')"
/>
@php
$period = $report['period'];
@endphp
<section class="grid grid-cols-1 gap-4 sm:grid-cols-4">
<x-portal.stat-card variant="primary" :label="__('Zeitraum')" :value="$period['start']->format('d.m.y').' – '.$period['end']->format('d.m.y')">
<x-slot:meta>{{ __(':percent % der Zeit vergangen', ['percent' => (int) round($period['elapsed_share'] * 100)]) }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Zwischenblick')" :value="$period['review']->format('d.m.Y')">
<x-slot:meta>{{ __('danach Audit im Quartal') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card :variant="$report['umami']['error'] === null ? 'ok' : 'warn'" :label="__('Umami')" :value="$report['umami']['error'] === null ? __('verbunden') : __('nicht verbunden')">
<x-slot:meta>{{ $report['umami']['error'] ?? __('zwischengespeichert, siehe unten') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Quelle')" value="growth:report">
<x-slot:meta>{{ __('dieselben Zahlen auf der Konsole') }}</x-slot:meta>
</x-portal.stat-card>
</section>
@foreach ($report['blocks'] as $block)
<article class="panel overflow-hidden" wire:key="block-{{ $block['key'] }}">
<div class="panel-head">
<span class="section-eyebrow">{{ $block['title'] }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $block['consequence'] }}</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Bedingung') }}</flux:table.column>
<flux:table.column>{{ __('Ist') }}</flux:table.column>
<flux:table.column>{{ __('Soll heute') }}</flux:table.column>
<flux:table.column>{{ __('Schwelle') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($block['criteria'] as $criterion)
<flux:table.row :key="$block['key'].'-'.$criterion['key']">
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $criterion['label'] }}</span>
@if ($criterion['note'])
<span class="block max-w-xl whitespace-normal text-[11px] text-[color:var(--color-ink-4)]">{{ $criterion['note'] }}</span>
@endif
</flux:table.cell>
<flux:table.cell><span class="text-[13px] font-semibold tabular-nums text-[color:var(--color-ink)]">{{ $criterion['actual'] ?? '—' }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-3)]">{{ $criterion['expected'] ?? '—' }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ $criterion['threshold'] }}</span></flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" :color="GrowthReport::statusColor($criterion['status'])">{{ GrowthReport::statusLabel($criterion['status']) }}</flux:badge>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
</article>
@endforeach
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Trichter seit :date', ['date' => $period['start']->format('d.m.Y')]) }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('eindeutige Firmen je Stufe; „ohne Firma“ steht daneben, nicht im Nenner') }}</span>
</div>
@forelse ($funnel['sources'] as $source)
<div class="px-5 pt-4 text-[12px] font-semibold text-[color:var(--color-ink-2)]">{{ $source['label'] }}</div>
<flux:table>
<flux:table.columns>
<flux:table.column>#</flux:table.column>
<flux:table.column>{{ __('Stufe') }}</flux:table.column>
<flux:table.column>{{ __('Firmen') }}</flux:table.column>
<flux:table.column>{{ __('Übergang') }}</flux:table.column>
<flux:table.column>{{ __('ohne Firma') }}</flux:table.column>
<flux:table.column>{{ __('Ein Abbruch hier bedeutet') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($source['steps'] as $step)
<flux:table.row :key="$source['source'].'-'.$step['type']">
<flux:table.cell><span class="text-[12px] tabular-nums text-[color:var(--color-ink-4)]">{{ $step['step'] }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12.5px] text-[color:var(--color-ink)]">{{ $step['label'] }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12.5px] font-semibold tabular-nums text-[color:var(--color-ink)]">{{ $step['firmen'] }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ $step['uebergang'] === null ? '–' : number_format($step['uebergang'], 1, ',', '.').' %' }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-3)]">{{ $step['ohne_firma'] ?: '' }}</span></flux:table.cell>
<flux:table.cell><span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $step['diagnosis'] }}</span></flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
@empty
<div class="px-5 py-4 text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Keine Ereignisse im Zeitraum.') }}</div>
@endforelse
</article>
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Transparenzseiten · letzte 30 Tage') }}</span>
<flux:button size="sm" variant="ghost" icon="arrow-path" wire:click="refreshUmami">{{ __('Umami neu abfragen') }}</flux:button>
</div>
@if ($report['pages'] === [])
<div class="px-5 py-4 text-[12.5px] text-[color:var(--color-ink-3)]">{{ $report['umami']['error'] ?? __('Keine Daten.') }}</div>
@else
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column>{{ __('Seite') }}</flux:table.column>
<flux:table.column>{{ __('Aufrufe') }}</flux:table.column>
<flux:table.column>{{ __('aus der Suche') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($report['pages'] as $row)
<flux:table.row :key="'page-'.$row['brand'].'-'.$row['page']">
<flux:table.cell><span class="text-[12.5px] text-[color:var(--color-ink-2)]">{{ $row['brand'] }}</span></flux:table.cell>
<flux:table.cell><span class="font-mono text-[12px] text-[color:var(--color-ink)]">{{ $row['page'] }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ $row['views'] }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12.5px] font-semibold tabular-nums text-[color:var(--color-ink)]">{{ $row['search_views'] }}</span></flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
@endif
</article>
</div>

View file

@ -161,6 +161,10 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
// nach Direkteinreichung/Syndikation — Grundlage für die Kundenansicht.
Route::livewire('admin/reports/indexierung', 'admin.reports.index-status')->name('admin.reports.index-status');
// Wachstum (Roadmap 6.1, 6.16): Abbruchbedingungen der Blöcke 1 bis 3,
// Trichter seit Beginn des Zeitraums, Transparenzseiten aus Umami.
Route::livewire('admin/reports/wachstum', 'admin.reports.growth')->name('admin.reports.growth');
Route::middleware('can:settings:manage')->group(function (): void {
Route::livewire('admin/reports/system-health', 'admin.reports.system-health')->name('admin.reports.system-health');
});

View file

@ -0,0 +1,225 @@
<?php
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Models\Company;
use App\Models\ConversionEvent;
use App\Models\FeedFetch;
use App\Models\User;
use App\Services\Analytics\UmamiClient;
use App\Services\Analytics\UmamiUnavailable;
use App\Services\Growth\GrowthReport;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
/**
* Admin ▸ Reports ▸ Wachstum (Roadmap 6.1, 6.16): Abbruchbedingungen der
* Blöcke 1 bis 3 aus eigenen Ereignisdaten und Umami.
*/
beforeEach(function (): void {
Http::preventStrayRequests();
Cache::flush();
config()->set('services.umami.api_url', null);
});
function connectUmami(): void
{
config()->set('services.umami.api_url', 'https://umami.test');
config()->set('services.umami.api_username', 'reports');
config()->set('services.umami.api_password', 'geheim');
}
function growthEvent(ConversionEventType $type, ConversionSource $source, ?Company $company, string $at = '2026-10-01'): void
{
ConversionEvent::query()->create(['type' => $type, 'source' => $source, 'company_id' => $company?->id])
->forceFill(['created_at' => Carbon::parse($at)])->save();
}
function growthCriterion(array $report, string $key): array
{
foreach ($report['blocks'] as $block) {
foreach ($block['criteria'] as $criterion) {
if ($criterion['key'] === $key) {
return $criterion;
}
}
}
throw new RuntimeException('Bedingung fehlt: '.$key);
}
// ----------------------------------------------------------- Umami-Client
test('the umami client logs in once, keeps the token and sends the range in milliseconds', function () {
/** @var TestCase $this */
connectUmami();
Http::fake([
'umami.test/api/auth/login' => Http::response(['token' => 'tok-1']),
'umami.test/api/websites/*/stats*' => Http::response(['pageviews' => 12, 'visitors' => 5, 'visits' => 7, 'bounces' => 2, 'totaltime' => 90]),
]);
$client = app(UmamiClient::class);
$from = Carbon::parse('2026-09-17 00:00:00', 'UTC');
$to = Carbon::parse('2026-10-13 00:00:00', 'UTC');
$client->stats('site-1', $from, $to, ['path' => 'c.preise']);
$stats = $client->stats('site-1', $from, $to);
expect($stats['pageviews'])->toBe(12);
Http::assertSentCount(3);
Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/api/websites/site-1/stats')
&& (string) ($request->data()['startAt'] ?? '') === (string) $from->getTimestampMs()
&& ($request->data()['path'] ?? null) === 'c.preise'
&& $request->hasHeader('Authorization', 'Bearer tok-1'));
});
test('an expired token is renewed once', function () {
/** @var TestCase $this */
connectUmami();
Http::fake([
'umami.test/api/auth/login' => Http::sequence()->push(['token' => 'alt'])->push(['token' => 'neu']),
'umami.test/api/websites/*' => Http::sequence()->push([], 401)->push([['x' => 'google.com', 'y' => 4]]),
]);
$rows = app(UmamiClient::class)->metrics('site-1', 'referrer', now()->subDay(), now());
expect($rows)->toBe([['x' => 'google.com', 'y' => 4]]);
});
test('a rejected login is reported as unavailable, never as an empty result', function () {
/** @var TestCase $this */
connectUmami();
Http::fake(['umami.test/api/auth/login' => Http::response(['error' => 'incorrect-username-password'], 401)]);
app(UmamiClient::class)->stats('site-1', now()->subDay(), now());
})->throws(UmamiUnavailable::class, 'Umami-Anmeldung abgelehnt (401).');
// -------------------------------------------------------------- Bericht
test('block one counts companies from mail and claim, not organic ones and not before the start', function () {
/** @var TestCase $this */
[$a, $b, $c] = Company::factory()->count(3)->create();
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $a);
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $a, '2026-10-02');
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::ProfileClaim, $b);
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::Organic, $c);
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $c, '2026-09-01');
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, null);
growthEvent(ConversionEventType::FirstBooking, ConversionSource::MailPublish, $a);
growthEvent(ConversionEventType::FirstBooking, ConversionSource::Organic, $b);
growthEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailReject, $c);
$report = app(GrowthReport::class)->build(now: Carbon::parse('2026-10-13'));
$claims = growthCriterion($report, 'claims');
expect($claims['actual'])->toBe(2)
->and($claims['note'])->toContain('1 Einlösung ohne Firmenbezug')
->and(growthCriterion($report, 'bookings')['actual'])->toBe(1)
->and(growthCriterion($report, 'reject_clicks')['actual'])->toBe(1);
});
test('cumulative conditions compare against the share of time that has passed', function () {
/** @var TestCase $this */
$companies = Company::factory()->count(16)->create();
foreach ($companies as $company) {
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $company);
}
// Halbzeit (17.12.): Soll 15 von 30.
$mid = app(GrowthReport::class)->build(now: Carbon::parse('2026-12-17'));
expect(growthCriterion($mid, 'claims')['status'])->toBe('on_track')
->and(growthCriterion($mid, 'bookings')['status'])->toBe('behind');
// Nach dem Ende ist „noch nicht“ ein „verfehlt“.
$after = app(GrowthReport::class)->build(now: Carbon::parse('2027-03-20'));
expect(growthCriterion($after, 'claims')['status'])->toBe('missed');
});
test('block three sums the subscribers reader services report', function () {
/** @var TestCase $this */
FeedFetch::factory()->reader('feedly', 12)->create();
FeedFetch::factory()->category('it')->reader('inoreader', 4)->create();
FeedFetch::factory()->reader(null)->create();
expect(growthCriterion(app(GrowthReport::class)->build(), 'feed_subscribers')['actual'])->toBe(16);
});
test('without umami the search condition says why instead of showing zero', function () {
/** @var TestCase $this */
$report = app(GrowthReport::class)->build();
$search = growthCriterion($report, 'search_views');
expect($search['actual'])->toBeNull()
->and($search['status'])->toBe('unknown')
->and($report['umami']['error'])->toContain('nicht verbunden');
});
test('block two counts only visits from search engines', function () {
/** @var TestCase $this */
connectUmami();
config()->set('growth.block2.brands', ['businessportal24']);
config()->set('growth.block2.pages', ['was-kostet-eine-pressemitteilung']);
Http::fake([
'umami.test/api/auth/login' => Http::response(['token' => 'tok']),
'umami.test/api/websites/*/stats*' => Http::response(['pageviews' => 9]),
'umami.test/api/websites/*/metrics*' => Http::response([
['x' => 'google.com', 'y' => 3],
['x' => 'duckduckgo.com', 'y' => 1],
['x' => 'pressekonto.com', 'y' => 4],
]),
]);
$report = app(GrowthReport::class)->build();
expect(growthCriterion($report, 'search_views')['actual'])->toBe(4)
->and($report['pages'][0])->toMatchArray(['views' => 9, 'search_views' => 4]);
Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'metrics') && ($request->data()['path'] ?? null) === 'c.was-kostet-eine-pressemitteilung');
});
// --------------------------------------------------- Befehle und Seite
test('the growth command prints every block', function () {
/** @var TestCase $this */
$this->artisan('growth:report')
->expectsOutputToContain('Block 1 — Konversion aus dem Bestand')
->expectsOutputToContain('Block 2 — Organische Sichtbarkeit')
->expectsOutputToContain('Block 3 — Reichweite')
->assertSuccessful();
});
test('the umami command refuses to run without access data', function () {
/** @var TestCase $this */
$this->artisan('umami:report')
->expectsOutputToContain('Umami-API nicht konfiguriert')
->assertFailed();
});
test('admins see the growth page', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin)
->get(route('admin.reports.growth'))
->assertSuccessful()
->assertSeeText('Wachstum')
->assertSeeText('Firmen mit eingelöstem Verwaltungslink')
->assertSeeText('Gemeldete Feed-Abonnenten (30 Tage)')
->assertSeeText('Umami-API nicht verbunden');
});
test('customers do not see the growth page', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$this->actingAs($customer)->get(route('admin.reports.growth'))->assertForbidden();
});