diff --git a/CHANGELOG.md b/CHANGELOG.md index f3de307..6fa849b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,25 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +## [0.9.31] - 2026-06-20 + +### Geändert +- **Versions-Verlauf als Serien-Browser statt langer Liste.** Releases sind jetzt nach + Hauptversion (major.minor, z. B. „0.9") gruppiert: eine Serien-Navigation (ab großen Bildschirmen + eine seitliche Leiste, darunter horizontale Pills) wählt die Reihe, rechts erscheinen deren + Releases als Accordion (das neueste offen) mit **Seitenblätterung** — kein endloses Scrollen über + alle Versionen mehr. Die installierte Reihe ist markiert, jede Zeile zeigt die Änderungs-Kategorien + als farbige Punkte auf einen Blick. + +### Behoben +- **Aufklapp-Pfeil & „Weiter"-Knopf sichtbar.** Das `chevron-right`-Icon fehlte im Icon-Satz, sodass + der Accordion-Pfeil jeder Zeile und der „Weiter"-Knopf der Seitenblätterung als leeres SVG + gerendert wurden (unsichtbare Bedienelemente). Icon ergänzt. +- **Aufgeklappter Eintrag bleibt offen.** Ein selbst aufgeklapptes Release wird nicht mehr durch + einen Hintergrund-Re-Render (Update-Prüfung) wieder zugeklappt (`wire:ignore.self`). +- **Touch-Zielgrößen.** Serien-Pills und Seiten-Knöpfe erfüllen auf kleinen Bildschirmen jetzt die + 44-px-Touch-Mindestgröße. + ## [0.9.30] - 2026-06-20 ### Behoben diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index a0a4841..776f417 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -36,6 +36,15 @@ class Index extends Component */ private const UPDATE_FLAG = 'clusev:update-in-progress'; + /** Patch releases shown per page within a series — keeps the changelog from endless-scrolling. */ + private const CHANGELOG_PER_PAGE = 8; + + /** Selected major.minor changelog series ('' = auto: the newest). Drives the series rail. */ + public string $series = ''; + + /** 1-based page within the selected series' release list. */ + public int $changelogPage = 1; + public ?string $lastChecked = null; public ?string $updateStatus = null; @@ -417,6 +426,103 @@ class Index extends Component return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch]; } + /** Switch the visible major.minor series and jump back to its first page. */ + public function selectSeries(string $series): void + { + $this->series = $series; + $this->changelogPage = 1; + } + + /** Jump to a page within the active series (lower bound clamped here; upper clamp in render). */ + public function gotoChangelogPage(int $page): void + { + $this->changelogPage = max(1, $page); + } + + /** + * Group parsed releases into major.minor series (e.g. "0.9"), newest series first. Each series + * keeps its releases (still newest-first) plus a count and the latest date. An Unreleased node, + * if present, becomes its own pseudo-series pinned to the top. This is what lets the changelog + * show a compact series index + paginated detail instead of one endless list. + * + * @param array>}> $releases + * @return array>}> + */ + private function groupBySeries(array $releases): array + { + $buckets = []; + foreach ($releases as $rel) { + if ($rel['unreleased']) { + $key = 'unreleased'; + } elseif (preg_match('/^v?(\d+)\.(\d+)\./', $rel['version'], $m)) { + $key = $m[1].'.'.$m[2]; + } else { + $key = 'other'; + } + $buckets[$key][] = $rel; + } + + $series = []; + foreach ($buckets as $key => $rels) { + $dates = array_values(array_filter(array_map(static fn (array $r): ?string => $r['date'], $rels))); + $series[] = [ + 'key' => $key, + 'label' => match ($key) { + 'unreleased' => __('versions.unreleased'), + 'other' => __('versions.series_other'), + default => $key, + }, + 'unreleased' => $key === 'unreleased', + 'count' => count($rels), + 'latest' => $dates[0] ?? null, // releases are newest-first → the first date is the latest + 'releases' => $rels, + ]; + } + + // Unreleased first, then series by version descending, 'other' last. + usort($series, static function (array $a, array $b): int { + $rank = static fn (array $s): int => $s['key'] === 'unreleased' ? 0 : ($s['key'] === 'other' ? 2 : 1); + $ra = $rank($a); + $rb = $rank($b); + if ($ra !== $rb) { + return $ra <=> $rb; + } + + return $ra === 1 ? version_compare($b['key'].'.0', $a['key'].'.0') : 0; + }); + + return $series; + } + + /** + * Compact pagination window — the full range up to 7 pages, otherwise first + last plus a + * 3-wide window around the current page with '…' gaps. Returns ints and '…' string markers. + * + * @return array + */ + private function pageWindow(int $current, int $total): array + { + if ($total <= 7) { + return range(1, $total); + } + + $pages = [1]; + $from = max(2, $current - 1); + $to = min($total - 1, $current + 1); + if ($from > 2) { + $pages[] = '…'; + } + for ($i = $from; $i <= $to; $i++) { + $pages[] = $i; + } + if ($to < $total - 1) { + $pages[] = '…'; + } + $pages[] = $total; + + return $pages; + } + /** * Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its * version, optional date, and the changes grouped by Keep-a-Changelog @@ -492,12 +598,36 @@ class Index extends Component public function render() { $releases = $this->releases(); + $seriesList = $this->groupBySeries($releases); + + // Active series: keep the operator's choice while it still exists, else the newest. + $keys = array_column($seriesList, 'key'); + $activeKey = in_array($this->series, $keys, true) ? $this->series : ($keys[0] ?? ''); + $activeSeries = null; + foreach ($seriesList as $s) { + if ($s['key'] === $activeKey) { + $activeSeries = $s; + break; + } + } + + // Paginate the active series' releases so a long history never becomes an endless scroll. + $activeReleases = $activeSeries['releases'] ?? []; + $totalPages = max(1, (int) ceil(count($activeReleases) / self::CHANGELOG_PER_PAGE)); + $page = min(max(1, $this->changelogPage), $totalPages); + $this->changelogPage = $page; // make render the single clamp authority — no stale out-of-range value lingers + $pagedReleases = array_slice($activeReleases, ($page - 1) * self::CHANGELOG_PER_PAGE, self::CHANGELOG_PER_PAGE); + + // Mark the series that holds the running version (a small "installed" cue in the rail). + $installed = ltrim((string) config('clusev.version'), 'vV'); + $currentSeries = preg_match('/^(\d+)\.(\d+)\./', $installed, $m) ? $m[1].'.'.$m[2] : null; + // Passive (no network): cached remote result if a check has run, else local .git. $latestTag = $this->resolveLatestTag($this->channel()); // A stale cache can hold a "latest" older than what's now installed (e.g. right after an // update, before the next check). Never present yourself as behind a release you've // already passed — hide it until a fresh check resolves the true latest. - if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), ltrim((string) config('clusev.version'), 'vV'), '<')) { + if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), $installed, '<')) { $latestTag = null; } @@ -507,7 +637,15 @@ class Index extends Component 'repository' => config('clusev.repository'), 'license' => config('clusev.license'), 'build' => $this->build(), - 'releases' => $releases, + 'seriesList' => $seriesList, + 'activeKey' => $activeKey, + 'activeSeries' => $activeSeries, + 'pagedReleases' => $pagedReleases, + 'changelogPage' => $page, + 'totalPages' => $totalPages, + 'pageWindow' => $this->pageWindow($page, $totalPages), + 'currentSeries' => $currentSeries, + 'totalReleases' => count($releases), 'latestTag' => $latestTag, ])->title(__('versions.title')); } diff --git a/config/clusev.php b/config/clusev.php index 1251b08..3a6fb03 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -2,7 +2,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). - 'version' => '0.9.30', + 'version' => '0.9.31', // Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod // image ships no .git); the Versions page prefers them and falls back to a live .git read in diff --git a/lang/de/versions.php b/lang/de/versions.php index b43ee77..a28d781 100644 --- a/lang/de/versions.php +++ b/lang/de/versions.php @@ -25,6 +25,16 @@ return [ 'in_progress' => 'in Arbeit', 'changelog_empty' => 'Kein Änderungsprotokoll gefunden.', + // Series index + pagination (Änderungsprotokoll nach major.minor-Reihen gruppiert) + 'series_nav' => 'Versionsreihen', + 'series_label' => 'Serie :series', + 'series_count' => '{1} :count Release|[2,*] :count Releases', + 'series_other' => 'Sonstige', + 'series_installed' => 'Installierte Reihe', + 'pagination_nav' => 'Seiten', + 'pagination_prev' => 'Zurück', + 'pagination_next' => 'Weiter', + // Changelog section labels (Keep a Changelog subsections) 'section_added' => 'Hinzugefügt', 'section_changed' => 'Geändert', diff --git a/lang/en/versions.php b/lang/en/versions.php index 654a61c..17d2f65 100644 --- a/lang/en/versions.php +++ b/lang/en/versions.php @@ -25,6 +25,16 @@ return [ 'in_progress' => 'in progress', 'changelog_empty' => 'No changelog found.', + // Series index + pagination (changelog grouped by major.minor series) + 'series_nav' => 'Version series', + 'series_label' => 'Series :series', + 'series_count' => '{1} :count release|[2,*] :count releases', + 'series_other' => 'Other', + 'series_installed' => 'Installed series', + 'pagination_nav' => 'Pages', + 'pagination_prev' => 'Previous', + 'pagination_next' => 'Next', + // Changelog section labels (Keep a Changelog subsections) 'section_added' => 'Added', 'section_changed' => 'Changed', diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index 32038a6..5b1dda7 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -4,6 +4,7 @@ $paths = [ 'menu' => '', 'chevron-left' => '', + 'chevron-right' => '', 'tag' => '', 'help-circle' => '', 'lock' => '', diff --git a/resources/views/livewire/versions/index.blade.php b/resources/views/livewire/versions/index.blade.php index ad7ca2b..a8a24b9 100644 --- a/resources/views/livewire/versions/index.blade.php +++ b/resources/views/livewire/versions/index.blade.php @@ -15,6 +15,8 @@ ]; $sectionLabel = fn (string $g): string => isset($sectionKey[$g]) ? __('versions.section_'.$sectionKey[$g]) : $g; $tone = fn (string $g): string => $sectionTone[$sectionKey[$g] ?? ''] ?? 'neutral'; + // Toned glance-dots on each collapsed release row (static classes so Tailwind's scanner sees them). + $dotClass = ['accent' => 'bg-accent', 'cyan' => 'bg-cyan', 'neutral' => 'bg-ink-4']; $repoHost = parse_url($repository, PHP_URL_HOST); $repoPath = trim((string) parse_url($repository, PHP_URL_PATH), '/'); @@ -92,80 +94,140 @@
- {{-- Release history BY VERSION --}} - -
- @forelse ($releases as $rel) - @if ($loop->first) - {{-- Latest version: always open, not collapsible --}} -
-
- @if ($rel['unreleased']) - {{ __('versions.unreleased') }} - {{ __('versions.in_progress') }} - @else - v{{ $rel['version'] }} - @if ($rel['date']) - {{ $rel['date'] }} + {{-- Release history — grouped into major.minor series, paginated (no endless scroll) --}} + + @if (empty($seriesList)) +

{{ __('versions.changelog_empty') }}

+ @else +
+ {{-- Series index: horizontal pills on small/medium screens, a vertical rail from xl --}} + + + {{-- Releases for the active series --}} +
+ @if ($activeSeries) + {{-- Series sub-header: which series, how many releases, latest date --}} +
+ + @if ($activeSeries['unreleased'] || $activeSeries['key'] === 'other') + {{ $activeSeries['label'] }} + @else + {{ __('versions.series_label', ['series' => $activeSeries['label']]) }} @endif + + {{ trans_choice('versions.series_count', $activeSeries['count'], ['count' => $activeSeries['count']]) }} + @if ($activeSeries['latest']) + {{ $activeSeries['latest'] }} @endif
-
- @foreach ($rel['groups'] as $group => $items) -
-
- {{ $sectionLabel($group) }} -
-
    - @foreach ($items as $text) -
  • - - {{ $text }} -
  • + + {{-- Accordion: the first release of each page is open, the rest collapse --}} +
    + @foreach ($pagedReleases as $rel) + {{-- wire:ignore.self keeps a user's open/closed toggle from being reset when an + unrelated re-render (autoCheck / check-updates / the in-update poll) morphs the + page; a series/page change uses a fresh wire:key, so it re-defaults to first-open. --}} +
    first) open @endif> + + + @if ($rel['unreleased']) + {{ __('versions.unreleased') }} + {{ __('versions.in_progress') }} + @else + v{{ $rel['version'] }} + @if ($rel['date']) + {{ $rel['date'] }} + @endif + @endif + {{-- Toned glance-dots: which change categories this release has --}} + + +
    + @foreach ($rel['groups'] as $group => $items) +
    +
    + {{ $sectionLabel($group) }} +
    +
      + @foreach ($items as $text) +
    • + + {{ $text }} +
    • + @endforeach +
    +
    @endforeach -
-
+
+ @endforeach
-
- @else - {{-- Older versions: collapsible
accordion --}} -
- - - @if ($rel['unreleased']) - {{ __('versions.unreleased') }} - {{ __('versions.in_progress') }} - @else - v{{ $rel['version'] }} - @if ($rel['date']) - {{ $rel['date'] }} - @endif - @endif - -
- @foreach ($rel['groups'] as $group => $items) -
-
- {{ $sectionLabel($group) }} -
-
    - @foreach ($items as $text) -
  • - - {{ $text }} -
  • - @endforeach -
+ + {{-- Pagination within the active series --}} + @if ($totalPages > 1) +
-
- @endif - @empty -

{{ __('versions.changelog_empty') }}

- @endforelse -
+ + + + @endif + @endif +
+
+ @endif
{{-- Aside --}} diff --git a/tests/Feature/VersionsChangelogTest.php b/tests/Feature/VersionsChangelogTest.php new file mode 100644 index 0000000..5956760 --- /dev/null +++ b/tests/Feature/VersionsChangelogTest.php @@ -0,0 +1,151 @@ +clearUpdateRequest(); + $this->actingAs(User::factory()->create(['must_change_password' => false])); + } + + protected function tearDown(): void + { + app(DeploymentService::class)->clearUpdateRequest(); + parent::tearDown(); + } + + public function test_releases_are_grouped_into_series_ordered_newest_first(): void + { + Livewire::test(Index::class)->assertViewHas('seriesList', function ($series): bool { + if (! is_array($series) || count($series) < 2) { + return false; + } + // Newest series first: each key sorts >= the next (compare on "X.Y.0"). + for ($i = 0; $i < count($series) - 1; $i++) { + if (version_compare($series[$i]['key'].'.0', $series[$i + 1]['key'].'.0', '<')) { + return false; + } + } + + return true; + }); + } + + public function test_default_active_series_is_the_newest_one(): void + { + $firstKey = null; + + $component = Livewire::test(Index::class) + ->assertViewHas('seriesList', function ($series) use (&$firstKey): bool { + $firstKey = $series[0]['key'] ?? null; + + return true; + }); + + $component->assertViewHas('activeKey', fn ($key): bool => $key === $firstKey); + } + + public function test_an_unknown_selected_series_falls_back_to_the_newest(): void + { + Livewire::test(Index::class) + ->call('selectSeries', '99.99') + ->assertViewHas('activeKey', fn ($key): bool => $key !== '99.99' && $key !== ''); + } + + public function test_selecting_a_series_switches_the_visible_releases_and_resets_the_page(): void + { + // Default view shows the newest series, so the very first release (v0.1.0) is NOT visible… + Livewire::test(Index::class)->assertDontSee('v0.1.0'); + + // …selecting the 0.1 series brings it into view and jumps back to page 1. + Livewire::test(Index::class) + ->set('changelogPage', 3) + ->call('selectSeries', '0.1') + ->assertSet('series', '0.1') + ->assertSet('changelogPage', 1) + ->assertSee('v0.1.0'); + } + + public function test_a_long_series_is_paginated_to_the_per_page_limit(): void + { + Livewire::test(Index::class) + ->call('selectSeries', '0.9') + ->assertViewHas('totalPages', fn ($total): bool => $total > 1) + ->assertViewHas('pagedReleases', fn ($releases): bool => is_array($releases) && count($releases) <= 8); + } + + public function test_paging_shows_a_different_slice_of_the_series(): void + { + $pageOne = null; + + Livewire::test(Index::class) + ->call('selectSeries', '0.9') + ->assertViewHas('pagedReleases', function ($releases) use (&$pageOne): bool { + $pageOne = array_column($releases, 'version'); + + return true; + }); + + Livewire::test(Index::class) + ->call('selectSeries', '0.9') + ->call('gotoChangelogPage', 2) + ->assertViewHas('pagedReleases', function ($releases) use (&$pageOne): bool { + $pageTwo = array_column($releases, 'version'); + + // Page 2 is non-empty and disjoint from page 1. + return $pageTwo !== [] && array_intersect($pageTwo, $pageOne) === []; + }); + } + + public function test_an_out_of_range_page_is_clamped(): void + { + Livewire::test(Index::class) + ->call('selectSeries', '0.9') + ->call('gotoChangelogPage', 9999) + ->assertViewHas('changelogPage', fn ($page): bool => $page >= 1) + ->assertViewHas('pagedReleases', fn ($releases): bool => is_array($releases) && count($releases) >= 1) + ->assertSee(__('versions.changelog_title')); + } + + public function test_the_installed_series_is_resolved_from_the_running_version(): void + { + config()->set('clusev.version', '0.7.3'); + + Livewire::test(Index::class)->assertViewHas('currentSeries', '0.7'); + } + + public function test_total_release_count_is_passed_for_the_panel_subtitle(): void + { + Livewire::test(Index::class)->assertViewHas('totalReleases', fn ($count): bool => is_int($count) && $count >= 1); + } + + public function test_chevron_right_icon_renders_a_real_path(): void + { + // The accordion disclosure caret and the pagination "Next" button both use x-icon + // name="chevron-right"; a missing map entry renders an empty (invisible affordance). + $html = Blade::render(''); + + $this->assertStringContainsString('