web: Jahresarchiv je Branche, Kategoriebeschreibungen, Portalname im Titel (Roadmap 2.3)

- /de/category/{slug}/archiv/{jahr} (CategoryArchivePageData,
  CategoryArchiveController, View kategorie-archiv): 30 Meldungen je
  Seite, neueste zuerst, Jahresleiste mit Zahlen; ohne Jahr das juengste
  Jahr mit Meldungen. Canonical mit ?page=N auf Folgeseiten, hreflang
  nur auf Seite 1, CollectionPage + BreadcrumbList. Gecacht bis Seite 3.
  Leere Jahre, unbekannte Kategorien, leere Folgeseiten: 404.
- Branchenseite: Jahresleiste unter dem Feed (archiveYears, eine
  gruppierte Query); Sitemap listet alle Jahresarchive.
- categories:seed-descriptions {--dry-run} {--force}: fuellt leere DE/EN
  Beschreibungen aus database/seeders/data/category-descriptions.php
  (14 Kategorien, sachlich, ohne Werbeaussagen), leert den Seiten-Cache.
- Branchenseite: Titel trug config('app.name') ("– pressekonto") statt
  des Portalnamens — jetzt presseecho/businessportal24.
- Protokoll Teil 5 (2.3 mit Befund: 0 von 14 Beschreibungen, Sonstiges =
  28 % des Bestands), Roadmap 2.3 abgehakt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 10:25:29 +00:00
parent b74f881ac8
commit 84ea617107
15 changed files with 793 additions and 13 deletions

View file

@ -0,0 +1,150 @@
<?php
namespace App\Services\Web;
use App\Models\Category;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPageCache;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\DB;
/**
* Jahresarchiv einer Branche (Roadmap 2.3): `/category/{slug}/archiv/{jahr}`.
* Die Branchenseite zeigt nur die jüngsten ~15 Meldungen; das Archiv macht
* den Bestand (34.000 Meldungen seit 2008) als stabile, verlinkte Seiten
* erreichbar. Jahr statt fortlaufender Seitenzahl, weil eine Jahresseite
* ihren Inhalt behält, während `?page=2` bei jeder neuen Meldung verrutscht.
*/
class CategoryArchivePageData
{
public const PerPage = 30;
/** Nur die ersten Seiten je Jahr werden gecacht (siehe NewsroomsPageData). */
private const MaxCachedPage = 3;
/**
* @return array<string, mixed>
*/
public function build(WebPortalContext $context, string $slug, ?int $year): array
{
$page = Paginator::resolveCurrentPage();
$build = fn (): array => $this->buildFresh($context, $slug, $year, $page);
if ($page > self::MaxCachedPage) {
return $build();
}
return WebPageCache::remember(
WebPageCache::key('category-archive', [$context->portal->value, $context->language, $slug, (string) $year, 'page', $page]),
$build
);
}
/**
* Jahre mit Meldungen einer Branche (inkl. Unterkategorien), absteigend
* — auch für die Jahresleiste auf der Branchenseite.
*
* @param list<int> $categoryIds
* @param list<string> $portalValues
* @return array<int, int> Jahr => Anzahl
*/
public static function yearsWithCounts(array $categoryIds, array $portalValues, string $language): array
{
$yearExpression = DB::connection()->getDriverName() === 'sqlite'
? "CAST(strftime('%Y', published_at) AS INTEGER)"
: 'YEAR(published_at)';
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive()
->whereIn('category_id', $categoryIds)
->selectRaw($yearExpression.' as year, COUNT(*) as releases_count')
->groupBy('year')
->orderByDesc('year')
->pluck('releases_count', 'year')
->map(fn ($count): int => (int) $count)
->all();
}
/**
* @return array<string, mixed>
*/
private function buildFresh(WebPortalContext $context, string $slug, ?int $year, int $page): array
{
$portalValues = $context->portalValues;
$language = $context->language;
$category = Category::query()
->whereIn('portal', $portalValues)
->where('is_active', true)
->whereHas('translations', fn (Builder $query) => $query->where('locale', $language)->where('slug', $slug))
->with([
'translations' => fn ($query) => $query->where('locale', $language),
'parent.translations' => fn ($query) => $query->where('locale', $language),
])
->first();
if (! $category) {
abort(404);
}
$categoryIds = Category::query()
->where('parent_id', $category->id)
->where('is_active', true)
->pluck('id')
->prepend($category->id)
->unique()
->values()
->all();
$years = self::yearsWithCounts($categoryIds, $portalValues, $language);
if ($years === []) {
abort(404);
}
// Ohne Jahr: das jüngste Jahr mit Meldungen (nicht das Kalenderjahr —
// eine Branche ohne Meldung im laufenden Jahr hätte sonst eine leere Seite).
$year ??= (int) array_key_first($years);
if (! isset($years[$year])) {
abort(404);
}
$releases = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive()
->whereIn('category_id', $categoryIds)
->whereBetween('published_at', [
now()->setDate($year, 1, 1)->startOfDay(),
now()->setDate($year, 12, 31)->endOfDay(),
])
->with([
'company:id,name,slug',
'category.translations' => fn ($query) => $query->where('locale', $language),
])
->orderByDesc('published_at')
->paginate(self::PerPage, ['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score'], 'page', $page);
if ($page > 1 && $releases->isEmpty()) {
abort(404);
}
return [
'category' => $category,
'categoryAlternateSlugs' => $category->translations()->whereIn('locale', ['de', 'en'])->pluck('slug', 'locale')->all(),
'year' => $year,
'years' => $years,
'releases' => $releases,
'totalReleases' => array_sum($years),
];
}
}

View file

@ -293,6 +293,8 @@ class CategoryPageData
'topNewsrooms' => $topNewsrooms,
'subCategories' => $subCategories,
'relatedCategories' => $relatedCategories,
// Jahresleiste ins Archiv (2.3) — eine gruppierte Query.
'archiveYears' => CategoryArchivePageData::yearsWithCounts($categoryIds, $portalValues, $language),
];
}
}