*/ public function build(Portal $portal, string $language = 'de'): array { $cacheKey = sprintf('web.home.%s.%s', $portal->value, $language); return Cache::remember( $cacheKey, self::CACHE_TTL_SECONDS, fn (): array => $this->buildFresh($portal, $language) ); } /** * @return array */ private function buildFresh(Portal $portal, string $language): array { $portalValues = [$portal->value, Portal::Both->value]; // Aktualitäts-Stichtag an den jüngsten Datensatz koppeln (historisches Archiv). $referenceNow = EditorialClock::reference($portalValues, $language); $recentSince = $referenceNow->copy()->subDays(7); $previousSince = $referenceNow->copy()->subDays(14); // Bereichsvergleich statt whereDate(): DATE(published_at) würde die // Index-Nutzung auf published_at verhindern (non-sargable). $todayRange = [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()]; $publishedQuery = static fn (): Builder => PressRelease::query() ->forPortalValues($portalValues) ->forLanguage($language) ->visibleInArchive(); $with = [ 'company', 'category.translations' => fn ($query) => $query->where('locale', $language), 'images' => fn ($query) => $query ->orderByDesc('is_preview') ->orderBy('sort_order') ->limit(1), ]; $oldestPublishedAt = $publishedQuery() ->oldest('published_at') ->value('published_at'); // Gebuchte Boosts (Decision-Update §2.2): geboostete PMs besetzen die // Top-Positionen vor der Chronologie — die jüngste als Lead, weitere // in der Seitenleiste. Die Views markieren sie über `boostedIds`. // Nur Startseiten-Boosts; der günstigere Kategorie-Boost kauft // ausschließlich die Branchenseite (Entscheidung 13.07.2026). $boostedReleases = $publishedQuery() ->boosted(BoostPlacement::Home) ->with($with) ->orderByDesc('published_at') ->limit(5) ->get(); $boostedIds = $boostedReleases->modelKeys(); $leadRelease = $boostedReleases->first() ?? $publishedQuery() ->with($with) ->orderByDesc('published_at') ->first(); // Ohne mediumText-Spalte `text`: die Seitenleisten-Teaser rendern nur // Titel/Meta, der Volltext wäre unnötiger I/O. $sideBoosted = $boostedReleases->slice(1)->take(4)->values(); $sideChronoSlots = 4 - $sideBoosted->count(); $sideReleases = $sideChronoSlots > 0 ? $publishedQuery() ->with($with) ->when($leadRelease, fn (Builder $query) => $query->where('id', '!=', $leadRelease->id)) ->when($boostedIds !== [], fn (Builder $query) => $query->whereNotIn('id', $boostedIds)) ->orderByDesc('published_at') ->limit($sideChronoSlots) ->get(['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score']) : collect(); $sideReleases = $sideBoosted->concat($sideReleases)->values(); $mostReadReleases = $publishedQuery() ->orderByDesc('hits') ->orderByDesc('published_at') ->limit(4) ->get(['id', 'slug', 'title', 'hits', 'portal', 'language']); $activeNewsrooms = Company::query() ->whereIn('portal', $portalValues) ->where('is_active', true) ->whereHas('pressReleases', function (Builder $query) use ($portalValues, $recentSince, $language): void { $query ->forPortalValues($portalValues) ->forLanguage($language) ->published() ->where('published_at', '>=', $recentSince); }) ->withCount([ 'pressReleases as recent_releases_count' => function (Builder $query) use ($portalValues, $recentSince, $language): void { $query ->forPortalValues($portalValues) ->forLanguage($language) ->published() ->where('published_at', '>=', $recentSince); }, 'pressReleases as today_releases_count' => function (Builder $query) use ($portalValues, $todayRange, $language): void { $query ->forPortalValues($portalValues) ->forLanguage($language) ->published() ->whereBetween('published_at', $todayRange); }, ]) ->orderByDesc('today_releases_count') ->orderByDesc('recent_releases_count') ->limit(6) ->get() ->map(fn (Company $company): array => [ 'name' => $company->name, 'slug' => $company->slug, 'initial' => mb_strtoupper(mb_substr((string) $company->name, 0, 1)), 'count' => (int) $company->recent_releases_count, 'today' => (int) $company->today_releases_count > 0, ]); $industryIndex = Category::query() ->with(['translations' => fn ($query) => $query->where('locale', $language)]) ->withCount([ 'pressReleases as recent_count' => function (Builder $query) use ($portalValues, $recentSince, $language): void { $query ->forPortalValues($portalValues) ->forLanguage($language) ->published() ->where('published_at', '>=', $recentSince); }, 'pressReleases as previous_count' => function (Builder $query) use ($portalValues, $recentSince, $previousSince, $language): void { $query ->forPortalValues($portalValues) ->forLanguage($language) ->published() ->where('published_at', '>=', $previousSince) ->where('published_at', '<', $recentSince); }, ]) ->whereIn('portal', $portalValues) ->where('is_active', true) ->whereNull('parent_id') ->orderByDesc('recent_count') ->limit(7) ->get() ->map(function (Category $category): ?array { $translation = $category->translations->first(); if (! $translation) { return null; } return [ 'name' => $translation->name, 'href' => $translation->slug ? route('kategorie', ['slug' => $translation->slug]) : route('web.home'), 'count' => (int) ($category->recent_count ?? 0), 'delta' => (int) ($category->recent_count ?? 0) - (int) ($category->previous_count ?? 0), ]; }) ->filter() ->values(); // "Heute im Fokus · Branche": aktivste Top-Rubrik im Stichtag-Fenster mit echten Meldungen. $spotlightCategory = Category::query() ->with(['translations' => fn ($query) => $query->where('locale', $language)]) ->withCount([ 'pressReleases as window_count' => function (Builder $query) use ($portalValues, $recentSince, $referenceNow, $language): void { $query ->forPortalValues($portalValues) ->forLanguage($language) ->published() ->where('published_at', '>=', $recentSince) ->where('published_at', '<=', $referenceNow); }, ]) ->whereIn('portal', $portalValues) ->where('is_active', true) ->whereNull('parent_id') ->orderByDesc('window_count') ->first(); $spotlight = null; if ($spotlightCategory && $spotlightTranslation = $spotlightCategory->translations->first()) { $spotlightBase = static fn (): Builder => PressRelease::query() ->forPortalValues($portalValues) ->forLanguage($language) ->visibleInArchive() ->where('category_id', $spotlightCategory->id); $spotlightReleases = $spotlightBase() ->with(['company']) ->orderByDesc('published_at') ->limit(3) ->get() ->map(static function (PressRelease $release) use ($spotlightTranslation): array { $publishedAt = $release->published_at; return [ 'time' => $publishedAt?->format('H:i') ?? '', 'date' => $publishedAt?->translatedFormat('j. M') ?? '', 'category' => $spotlightTranslation->name, 'title' => $release->title, 'company' => $release->company?->name ?? '', 'city' => null, 'minutes' => $release->readTimeMinutes(), 'href' => $release->slug ? route('release.detail', ['slug' => $release->slug]) : '#', ]; }); if ($spotlightReleases->isNotEmpty()) { $windowCount = (int) ($spotlightCategory->window_count ?? 0); $archiveCount = $spotlightBase()->count(); $activeCompanies = (clone $spotlightBase()) ->where('published_at', '>=', $recentSince) ->where('published_at', '<=', $referenceNow) ->distinct() ->count('company_id'); $spotlight = [ 'industry' => $spotlightTranslation->name, 'stats' => [ ['label' => __('web.spotlight.stat_window'), 'value' => number_format($windowCount, 0, ',', '.'), 'sub' => __('web.spotlight.stat_window_sub', ['date' => $referenceNow->translatedFormat('j. M Y')])], ['label' => __('web.spotlight.stat_companies'), 'value' => number_format($activeCompanies, 0, ',', '.'), 'sub' => __('web.spotlight.stat_companies_sub')], ['label' => __('web.spotlight.stat_archive'), 'value' => number_format($archiveCount, 0, ',', '.'), 'sub' => __('web.spotlight.stat_archive_sub')], ], 'releases' => $spotlightReleases->all(), ]; } } return [ 'leadRelease' => $leadRelease, 'sideReleases' => $sideReleases, 'boostedIds' => $boostedIds, 'mostReadReleases' => $mostReadReleases, 'activeNewsrooms' => $activeNewsrooms, 'industryIndex' => $industryIndex, 'spotlight' => $spotlight, 'homeStats' => [ 'publishedCount' => $publishedQuery()->count(), 'publishedToday' => $publishedQuery()->whereBetween('published_at', $todayRange)->count(), 'archiveSince' => $oldestPublishedAt ? (int) Carbon::parse($oldestPublishedAt)->format('Y') : null, ], ]; } }