diff --git a/app/Console/Commands/ReportPressReleaseViews.php b/app/Console/Commands/ReportPressReleaseViews.php
index 8fbeba7b..502c6371 100644
--- a/app/Console/Commands/ReportPressReleaseViews.php
+++ b/app/Console/Commands/ReportPressReleaseViews.php
@@ -98,6 +98,26 @@ class ReportPressReleaseViews extends Command
}
$this->table(['Klasse', 'PMs', 'Anteil'], $verteilung);
+
+ $kohorten = [];
+
+ foreach ($portal['cohorts'] as $cohort) {
+ $kohorten[] = [
+ $cohort['alter'],
+ $cohort['anzahl'],
+ $cohort['median'] ?? '–',
+ $cohort['mittelwert'] ?? '–',
+ ];
+ }
+
+ if ($kohorten !== []) {
+ $this->line(' Aufrufe nach Alter (nur Meldungen seit dem Relaunch)');
+ $this->table(['Alter der Meldung', 'PMs', 'Median', 'Mittelwert'], $kohorten);
+ // Der Gesamtmedian oben hängt an der Altersmischung des
+ // Fensters und ist zwischen zwei Messzeitpunkten nicht
+ // vergleichbar. Die einzelne Kohorte ist es.
+ $this->line(' Als Basiswert die KOHORTE festhalten, nicht den Gesamtmedian — nur sie ist später identisch erhebbar.');
+ }
}
return self::SUCCESS;
diff --git a/app/Services/PressRelease/ViewDistributionReport.php b/app/Services/PressRelease/ViewDistributionReport.php
index c92947aa..cb085d28 100644
--- a/app/Services/PressRelease/ViewDistributionReport.php
+++ b/app/Services/PressRelease/ViewDistributionReport.php
@@ -42,11 +42,18 @@ class ViewDistributionReport
*/
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,
- * portals: list}}>}>,
+ * portals: list}}>, cohorts: list}>,
* }
*/
public function build(Carbon $from, ?Carbon $to = null): array
@@ -72,6 +79,7 @@ class ViewDistributionReport
'portal' => $portal->value,
'label' => $portal->label(),
'segments' => $segments,
+ 'cohorts' => $this->cohorts($portal, $from, $to),
];
}
@@ -82,6 +90,58 @@ class ViewDistributionReport
];
}
+ /**
+ * 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.
+ *
+ * @return list
+ */
+ private function cohorts(Portal $portal, Carbon $from, Carbon $to): 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.
+ $newest = $to->copy()->subDays($lowerDays);
+ $oldest = $upperDays === null ? $from : $to->copy()->subDays($upperDays - 1);
+
+ if ($oldest->greaterThan($newest)) {
+ continue;
+ }
+
+ $hits = $this->hits($portal, $oldest->max($from), $newest->min($to), 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.
*
diff --git a/dev/2026-09-01/basiswerte-block0.md b/dev/2026-09-01/basiswerte-block0.md
new file mode 100644
index 00000000..0dc7e1df
--- /dev/null
+++ b/dev/2026-09-01/basiswerte-block0.md
@@ -0,0 +1,137 @@
+# Basiswerte Block 0
+
+**Erhoben:** 01.09.2026 auf dem Live-System
+**Fenster:** 01.06.2026 bis 01.09.2026
+**Erhoben mit:** `press-releases:view-distribution`, `press-releases:rejection-rate`
+
+Diese Werte sind der Vergleichspunkt für alles, was aus Block 1 folgt. Sie
+stehen fest, bevor die erste Maßnahme live geht — genau dafür existiert
+Block 0.
+
+---
+
+## 0.1 Aufrufzahlen
+
+### Gesamtverteilung im Fenster
+
+| Portal | PMs | Min | P10 | P25 | **Median** | P75 | P90 | Max | Mittelwert |
+|---|---|---|---|---|---|---|---|---|---|
+| Presseecho | 1436 | 1 | 43 | 72 | **98** | 164 | 212 | 412 | 118,6 |
+| Businessportal24 | 1491 | 7 | 81 | 127 | **200** | 279 | 351 | 1376 | 214,4 |
+
+Aufgeteilt nach Bestand:
+
+| Portal | Bestand | PMs | Median |
+|---|---|---|---|
+| Presseecho | seit Relaunch | 1172 | 100 |
+| Presseecho | Altbestand | 264 | 58 |
+| Businessportal24 | seit Relaunch | 1210 | 202 |
+| Businessportal24 | Altbestand | 281 | 189 |
+
+Häufigkeitsverteilung:
+
+| Klasse | Presseecho | Businessportal24 |
+|---|---|---|
+| 0–24 Aufrufe | 2 % | 0 % |
+| 25–49 | 11 % | 4 % |
+| 50–99 | 38 % | 11 % |
+| 100–199 | 37 % | 35 % |
+| 200–499 | 12 % | 48 % |
+| 500–999 | 0 % | 2 % |
+| 1000+ | 0 % | 0 % |
+
+### ⚠ Der Gesamtmedian ist NICHT der Basiswert
+
+`press_releases.hits` ist ein mitlaufender Gesamtzähler, und die Aufrufe wachsen
+nach der Veröffentlichung über Wochen weiter. Gemessen auf der Dev-Kopie
+(Stand 13.08.), nur Meldungen seit dem Relaunch:
+
+| Alter der Meldung | Presseecho ⌀ | Businessportal24 ⌀ |
+|---|---|---|
+| 0–6 Tage | 76 | 81 |
+| 7–13 Tage | 128 | 126 |
+| 14–20 Tage | 169 | 179 |
+| 21–27 Tage | 235 | 231 |
+
+Nach drei Wochen ist **kein Plateau erreicht** — die Kurve steigt weiter. Damit
+misst der Median über ein Drei-Monats-Fenster nicht, wie viele Aufrufe eine
+Meldung bekommt, sondern wie viel eine zufällig alte Meldung bisher gesammelt
+hat. Dieser Wert verschiebt sich mit der Altersmischung des Fensters; zwei
+Messungen zu verschiedenen Zeitpunkten wären nicht vergleichbar.
+
+**Vergleichbar ist nur die einzelne Alterskohorte.** Der Report gibt sie seit
+dem 01.09. mit aus. Als Basiswert festzuhalten ist die Zeile
+**„28–55 Tage"** je Portal — sie lässt sich in einem halben Jahr identisch
+erheben.
+
+> **Offen:** Die Kohortenwerte vom Live-System fehlen noch. Der Report ist
+> nachgerüstet, aber die Zahlen oben stammen aus dem Lauf davor. Vor dem
+> Livegang von Block 1 einmal nachziehen:
+> `php artisan press-releases:view-distribution --json > basiswert-0.1.json`
+
+---
+
+## 0.2 Ablehnungsquote je Quelle
+
+| Quelle | Eingereicht | Veröffentlicht | Abgelehnt | **Quote** |
+|---|---|---|---|---|
+| API: connektar.de Presseverteiler (#13993) | 2428 | 2369 | 59 | **2,4 %** |
+| direkt (Kundenbereich) | 13 | 13 | 0 | 0 % |
+| **gesamt** | **2441** | **2382** | **59** | **2,4 %** |
+
+### Nur eine API-Quelle
+
+Die Roadmap nannte ursprünglich „connektar, openPR, direkt". Das war ein
+Missverständnis: openPR ist kein zweiter Kanal, sondern der Presse-API-Verteiler
+von connektar — also derselbe Zulauf. Bestätigt am 01.09.2026.
+
+In den Daten: genau ein API-Token wurde je benutzt (User 13993). Der Report
+gruppiert trotzdem generisch je API-Client, damit ein späterer zweiter Kunde
+automatisch eine eigene Zeile bekommt.
+
+### ⚠ Was diese Zahl für Block 1d bedeutet
+
+59 Ablehnungen im Quartal sind eine schmale Grundlage. Grobe Abschätzung bei
+n ≈ 2400 und p = 2,4 %: Der Standardfehler liegt bei rund 0,3 Prozentpunkten,
+der eines Vorher-Nachher-Vergleichs bei rund 0,44.
+
+| Angestrebte Wirkung von 1d | Erkennbar nach einem Quartal? |
+|---|---|
+| Quote halbiert (2,4 % → 1,2 %) | ja, rund 2,7 Standardfehler |
+| Quote um 20 % gesenkt (2,4 % → 1,9 %) | nein, rund 1,1 Standardfehler |
+
+**Folge für die Abbruchbedingung von Block 1:** „Wiedereinreichung nach
+Ablehnung ist messbar" als Kriterium arbeitet mit rund 59 Fällen je Quartal.
+Ein Schwellwert muss dazu passen, sonst wird Block 1 an einem Rauschen
+gemessen.
+
+---
+
+## Was daraus für E2 folgt
+
+**E2 — Zeigt die Absenderansicht Rohzahlen oder eine Einordnung gegen den
+Portaldurchschnitt?**
+
+**Empfehlung: Rohzahlen.** Begründung:
+
+1. **Kein Bodensatz, den eine Einordnung kaschieren müsste.** Unter 25 Aufrufen
+ liegen 2 % (Presseecho) beziehungsweise 0 % (Businessportal24) der
+ Meldungen. Die Befürchtung aus der Roadmap — „fällt die Verteilung schwach
+ aus, werden Rohzahlen zum Nachteil" — tritt nicht ein.
+2. **Die Verteilungen sind nahezu symmetrisch.** Mittelwert zu Median: 118,6 zu
+ 98 und 214,4 zu 200. Es gibt keine Handvoll Ausreißer, die den Schnitt nach
+ oben zieht und die Mehrheit darunter zurücklässt.
+3. **Eine Einordnung gegen den Portaldurchschnitt wäre sogar falsch.** Wegen
+ der Wachstumskurve oben vergleicht sie eine zwei Tage alte Meldung gegen
+ einen Schnitt, der drei Monate alte Meldungen enthält — jede frische
+ Veröffentlichung sähe wie ein Misserfolg aus. Eine Einordnung müsste
+ zwingend altersgleich sein, und dann ist sie deutlich aufwendiger als der
+ Nutzen rechtfertigt.
+4. **Ein portalübergreifender Durchschnitt verbietet sich ohnehin.** Die
+ Mediane unterscheiden sich um den Faktor zwei (98 gegenüber 200). Jede
+ Einordnung müsste je Portal geführt werden.
+
+Wenn später doch eine Einordnung gewünscht ist, dann als **Perzentil-Angabe
+innerhalb der eigenen Alterskohorte** („liegt über 60 % der Meldungen
+vergleichbaren Alters auf diesem Portal") — nicht als Vergleich gegen einen
+Durchschnitt. Die Kohortenlogik dafür steht bereits im Report.
diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md
index 93135c70..4235edcf 100644
--- a/dev/2026-09-01/roadmap-2026.md
+++ b/dev/2026-09-01/roadmap-2026.md
@@ -10,7 +10,7 @@
Das Portal hat Traffic, aber der Traffic besteht überwiegend aus Lesern fremder Meldungen. Zwischen "Meldung wird gelesen" und "jemand bucht" existiert keine Strecke. Alles, was den Trichter oben verbreitert, wirkt erst, wenn er unten geschlossen ist.
-Der größte ungenutzte Bestand liegt bereits im System: Firmen, deren Meldungen über connektar und openPR eingehen und die kein Konto haben. Sie sind erfasst, ihre Kontaktadresse liegt vor, sie werden bereits transaktional benachrichtigt, und die Magic-Link-Verifikation dieser Adresse ist gebaut.
+Der größte ungenutzte Bestand liegt bereits im System: Firmen, deren Meldungen über connektar eingehen und die kein Konto haben (openPR ist kein zweiter Kanal, sondern der Presse-API-Verteiler von connektar — derselbe Zulauf). Sie sind erfasst, ihre Kontaktadresse liegt vor, sie werden bereits transaktional benachrichtigt, und die Magic-Link-Verifikation dieser Adresse ist gebaut.
**Grundsatz für die Reihenfolge:** erst messen, dann den Trichter unten schließen, dann oben verbreitern, dann Partner.
@@ -37,7 +37,7 @@ Ohne definierte Konversionspunkte ist keiner der folgenden Blöcke bewertbar. Di
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 0.1 | Basiswert Aufrufzahlen: Median und Verteilung je Portal über die letzten drei Monate | S | keine |
-| 0.2 | Basiswert Ablehnungsquote je Quelle (connektar, openPR, direkt), als wiederkehrende Auswertung angelegt | S | keine |
+| 0.2 | Basiswert Ablehnungsquote je Quelle (API-Client, direkt), als wiederkehrende Auswertung angelegt | S | keine |
| 0.3 | Tabelle `conversion_events` anlegen: Ereignistyp, Firmen-ID, Meldungs-ID, Quelle, Zeitstempel | S | keine |
| 0.4 | Herkunftsparameter in allen Mail-Links, gespeichert **auf dem Magic-Link-Token**, nicht in der Session | S | 0.3 |
| 0.5 | Ereignisse an den bestehenden Zustandsänderungen mitschreiben | M | 0.3, 0.4 |
diff --git a/tests/Feature/ConversionBaselineReportsTest.php b/tests/Feature/ConversionBaselineReportsTest.php
index da06a3ff..a8c249dc 100644
--- a/tests/Feature/ConversionBaselineReportsTest.php
+++ b/tests/Feature/ConversionBaselineReportsTest.php
@@ -112,6 +112,43 @@ test('the view distribution command renders without data', function () {
$this->artisan('press-releases:view-distribution')->assertSuccessful();
});
+test('the view distribution groups releases into age cohorts', function () {
+ /** @var TestCase $this */
+ baselineRelease(Portal::Presseecho, 10, ['published_at' => now()->subDays(2)]);
+ baselineRelease(Portal::Presseecho, 90, ['published_at' => now()->subDays(10)]);
+ baselineRelease(Portal::Presseecho, 300, ['published_at' => now()->subDays(40)]);
+
+ $data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
+ $cohorts = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['cohorts'])
+ ->keyBy('alter');
+
+ // `hits` wächst nach der Veröffentlichung über Wochen weiter. Nur innerhalb
+ // einer Alterskohorte ist der Wert zwischen zwei Messzeitpunkten
+ // vergleichbar — der Gesamtmedian hängt an der Altersmischung des Fensters.
+ expect($cohorts['0–6 Tage']['median'])->toBe(10)
+ ->and($cohorts['7–13 Tage']['median'])->toBe(90)
+ ->and($cohorts['28–55 Tage']['median'])->toBe(300);
+});
+
+test('the age cohorts leave out legacy stock', function () {
+ /** @var TestCase $this */
+ baselineRelease(Portal::Presseecho, 50, ['published_at' => now()->subDays(3)]);
+ baselineRelease(Portal::Presseecho, 5000, [
+ 'published_at' => now()->subDays(3),
+ 'legacy_portal' => Portal::Presseecho->value,
+ 'legacy_id' => 4712,
+ ]);
+
+ $data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
+ $cohorts = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['cohorts'])
+ ->keyBy('alter');
+
+ // Die Zähler des Altbestands stammen aus dem Altsystem und haben keine
+ // hier nachvollziehbare Vorgeschichte — sie würden die Kurve verfälschen.
+ expect($cohorts['0–6 Tage']['anzahl'])->toBe(1)
+ ->and($cohorts['0–6 Tage']['median'])->toBe(50);
+});
+
// ------------------------------------------------------- 0.2 Ablehnungsquote
test('the rejection rate is reported per submitting source', function () {