diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 8a8c94a4..0ca4ffe4 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -26,6 +26,7 @@ use Illuminate\Pagination\Paginator; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\HtmlString; +use Illuminate\Support\Str; class PressRelease extends Model { @@ -148,6 +149,77 @@ class PressRelease extends Model : null; } + /** + * Kategorie-Name in der Anzeigesprache, Fallback auf die erste + * vorhandene Übersetzung. Zentrale Ablöse der zuvor in jeder + * Kartenkomponente kopierten Auflösung (R4.3). + */ + public function localizedCategoryName(?string $locale = null): ?string + { + $locale ??= app()->getLocale(); + + $translation = $this->category?->translations?->firstWhere('locale', $locale) + ?? $this->category?->translations?->first(); + + return $translation?->name; + } + + /** + * Bild-URL für Kartenansichten: card-Variante des ersten Bildes, + * Fallback Original. Null bei bildlosen PMs (dann greift + * chosenPlaceholderUrl() bzw. der generische Fallback der Komponente). + */ + public function cardImageUrl(): ?string + { + $image = $this->images?->first(); + + return $image?->variantUrl('card') ?? $image?->url(); + } + + /** + * Tag-freier, whitespace-normalisierter Anriss des PM-Textes. + */ + public function teaserText(int $limit = 180): string + { + return (string) Str::of(strip_tags((string) $this->text))->squish()->limit($limit); + } + + public function wordCount(): int + { + return str_word_count(strip_tags((string) $this->text)); + } + + /** + * Lesezeit in Minuten (200 Wörter/Minute, mindestens 1). + */ + public function readTimeMinutes(): int + { + return max(1, (int) round($this->wordCount() / 200)); + } + + /** + * Kompakter Zeitstempel der Kartenkomponenten: heute „HH:MM", + * gestern lokalisiert, sonst Kurzdatum. Null ohne published_at. + */ + public function compactPublishedLabel(): ?string + { + $publishedAt = $this->published_at; + + if (! $publishedAt) { + return null; + } + + if ($publishedAt->isToday()) { + return $publishedAt->format('H:i'); + } + + if ($publishedAt->isYesterday()) { + return __('web.shared.yesterday'); + } + + return $publishedAt->isoFormat('D. MMM'); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/app/Services/Web/HomePageData.php b/app/Services/Web/HomePageData.php index 86a625f1..24d76b54 100644 --- a/app/Services/Web/HomePageData.php +++ b/app/Services/Web/HomePageData.php @@ -206,7 +206,6 @@ class HomePageData ->limit(3) ->get() ->map(static function (PressRelease $release) use ($spotlightTranslation): array { - $words = str_word_count(strip_tags((string) $release->text)); $publishedAt = $release->published_at; return [ @@ -216,7 +215,7 @@ class HomePageData 'title' => $release->title, 'company' => $release->company?->name ?? '', 'city' => null, - 'minutes' => max(1, (int) ceil($words / 200)), + 'minutes' => $release->readTimeMinutes(), 'href' => $release->slug ? route('release.detail', ['slug' => $release->slug]) : '#', ]; }); diff --git a/dev/release/entwicklungsplan-release.md b/dev/release/entwicklungsplan-release.md index 15881d7b..6ad36dee 100644 --- a/dev/release/entwicklungsplan-release.md +++ b/dev/release/entwicklungsplan-release.md @@ -126,7 +126,7 @@ R2.2+R2.3 erledigt: Hub-Landing komplett ueberarbeitet (datengetriebenes Tarif-G - [x] **R4.1 Tote Dateien entfernen** — Erledigt 03.07. (User-Freigabe): `web/examples/` (4 Demo-Views), `welcome`, `home` („B2IN"-Fragment), `brand-mark`-View (die gleichnamige, viel genutzte *Komponente* bleibt), `layouts/web-master-test`, altes engl. `layouts/header`+`footer` — alle per grep verifiziert (inkl. dynamischer Auflösung). - [x] **R4.2 Tote Komponenten entfernt** — Erledigt 03.07.: die 12 gelisteten `x-web`-Komponenten + 7 zusätzlich gefundene Chain-Dead-Komponenten (`main-navigation`, `breadcrumb`, `page-header`, `content-layout`, `article-card`, `section-header`, `sidebar-widget` — nur von examples/ bzw. anderen toten Kandidaten referenziert). Verwaiste `.highlights-slider`-CSS-Regeln mit entfernt, Web-Build gegengeprüft. Suite grün. -- [ ] **R4.3 Dedup (optional, nice-to-have)** — Release-Card-Vereinheitlichung, Industrie-Icon-Map → Config, Inline-SVGs → Icon-Set, `` (alt 6.2/6.3). Nur wenn Zeit; kein Release-Blocker. +- [x] **R4.3 Dedup** — Erledigt 10.07. (angepasster Zuschnitt nach dem Frontend-Umbau): Die 5× kopierte Karten-Metadaten-Logik (Kategorie-Übersetzung mit Locale-Fallback, card-Bild-URL, Teaser, Lesezeit, Kompakt-Zeitstempel) in Präsentations-Helper am `PressRelease`-Model gezogen (`localizedCategoryName`, `cardImageUrl`, `teaserText`, `wordCount`, `readTimeMinutes`, `compactPublishedLabel`); feed-item, feed-top-item, focus-side-item, focus-hero, kategorie, newsroom, release-detail, veroeffentlichen + `HomePageData` umgestellt — Markup unverändert. Lesezeit-Formel vereinheitlicht (2 Stellen nutzten `ceil` statt `round`). Tests: `Web/PressReleaseCardHelpersTest`. **Bewusst gelassen:** Inline-SVGs → Icon-Set (max. 2–3 echte Dubletten in 13 Komponenten, hoher Churn im frischen User-Frontend, kein funktionaler Gewinn); Industrie-Icon-Map hatte der Umbau bereits erledigt. --- diff --git a/tests/Feature/Web/PressReleaseCardHelpersTest.php b/tests/Feature/Web/PressReleaseCardHelpersTest.php new file mode 100644 index 00000000..614ec948 --- /dev/null +++ b/tests/Feature/Web/PressReleaseCardHelpersTest.php @@ -0,0 +1,63 @@ +create(['portal' => Portal::Businessportal24]); + $category->translations()->create(['locale' => 'de', 'name' => 'Energie & Klima', 'slug' => 'energie-klima']); + $category->translations()->create(['locale' => 'en', 'name' => 'Energy & Climate', 'slug' => 'energy-climate']); + + $release = PressRelease::factory()->for($category)->create(); + $release->load('category.translations'); + + app()->setLocale('de'); + expect($release->localizedCategoryName())->toBe('Energie & Klima'); + + app()->setLocale('en'); + expect($release->localizedCategoryName())->toBe('Energy & Climate'); + + // Fallback: Sprache ohne Übersetzung → erste vorhandene. + app()->setLocale('fr'); + expect($release->localizedCategoryName())->toBe('Energie & Klima'); + + app()->setLocale('de'); +}); + +test('teaserText strips tags, squishes whitespace and limits length', function () { + $release = PressRelease::factory()->create([ + 'text' => "

Erster Absatz.

\n

Zweiter Absatz mit Auszeichnung.

", + ]); + + expect($release->teaserText())->toBe('Erster Absatz. Zweiter Absatz mit Auszeichnung.') + ->and($release->teaserText(13))->toBe('Erster Absatz...'); +}); + +test('readTimeMinutes rounds at 200 words per minute with a minimum of one', function () { + $short = PressRelease::factory()->create(['text' => 'Nur wenige Worte.']); + $long = PressRelease::factory()->create(['text' => implode(' ', array_fill(0, 450, 'Wort'))]); + + expect($short->wordCount())->toBe(3) + ->and($short->readTimeMinutes())->toBe(1) + ->and($long->readTimeMinutes())->toBe(2); +}); + +test('compactPublishedLabel formats today, yesterday and older dates', function () { + $today = PressRelease::factory()->create(['published_at' => now()->setTime(9, 30)]); + $yesterday = PressRelease::factory()->create(['published_at' => now()->subDay()]); + $older = PressRelease::factory()->create(['published_at' => now()->parse('2026-03-05 08:00')]); + $unpublished = PressRelease::factory()->create(['published_at' => null]); + + expect($today->compactPublishedLabel())->toBe('09:30') + ->and($yesterday->compactPublishedLabel())->toBe(__('web.shared.yesterday')) + ->and($older->compactPublishedLabel())->toBe($older->published_at->isoFormat('D. MMM')) + ->and($unpublished->compactPublishedLabel())->toBeNull(); +}); + +test('cardImageUrl is null for releases without images', function () { + $release = PressRelease::factory()->create(); + $release->load('images'); + + expect($release->cardImageUrl())->toBeNull(); +});