From f60b7f4efa64a8fb9aaafaf3539d1ff7f4fe278d Mon Sep 17 00:00:00 2001 From: Anthony Baggett Date: Sat, 1 Aug 2026 18:26:07 -0500 Subject: [PATCH 1/2] Fix blank page after every region in region-day PDFs with page breaks .region got `page-break-after: always` in region-day mode, but the only suppressor was `.day:last-child` -- there was no `.region:last-child` counterpart. Mirrors the existing rule; `:last-child` (0,2,0) outranks the bare `.region` (0,1,0), so source order is unaffected. The new selector is inert in day-region mode, where .region never gets a break. The impact is much larger than a single trailing page. Feeds over 500 meetings take the chunked path in Controller::pdf(): each top-level group renders as its own standalone PDF and the results are merged with FPDI. In region-day mode every chunk holds exactly one region, so pre-fix that lone .region always emitted a trailing break -- producing a blank page at the end of *every* chunk, all of which survive the merge. A 60-region feed rendered 120 pages, 60 of them blank; with the fix it renders 60. This is also why day-region was never affected: `.day:last-child` was already doing this job for day chunks. Adds three regression tests: the chunked path (60 regions, >500 meetings, asserts one page per region -- fails at 120 pre-fix), plus the original small-feed region-day case and a day-region control that passes either way. The small-feed tests alone were insufficient: everything under 500 meetings takes a fast path that never exercises the merge. --- resources/views/pdf.blade.php | 3 +- tests/Feature/PdfTest.php | 89 ++++++++++++ tests/Fixtures/meetings-multi-region.json | 158 ++++++++++++++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/meetings-multi-region.json diff --git a/resources/views/pdf.blade.php b/resources/views/pdf.blade.php index 192e081..c34d10e 100644 --- a/resources/views/pdf.blade.php +++ b/resources/views/pdf.blade.php @@ -81,7 +81,8 @@ width: 40px; } - .day:last-child { + .day:last-child, + .region:last-child { page-break-after: auto; } diff --git a/tests/Feature/PdfTest.php b/tests/Feature/PdfTest.php index ffe3872..7e33156 100644 --- a/tests/Feature/PdfTest.php +++ b/tests/Feature/PdfTest.php @@ -59,6 +59,95 @@ public function pdf_generates_with_cjk_language(): void $this->assertSame('application/pdf', $response->headers->get('Content-Type')); } + #[Test] + public function region_day_page_breaks_do_not_emit_a_trailing_blank_page(): void + { + $this->fakeMultiRegionFeed(); + + $response = $this->get('/pdf?json=https://example.test/regions&group_by=region-day&options[]=pagebreaks'); + + $response->assertOk(); + $this->assertSame( + 3, + $this->countPdfPages($response->getContent()), + 'Expected one page per region (3); an extra page means the final region still emits page-break-after: always.' + ); + } + + #[Test] + public function day_region_page_breaks_do_not_emit_a_trailing_blank_page(): void + { + $this->fakeMultiRegionFeed(); + + $response = $this->get('/pdf?json=https://example.test/regions&group_by=day-region&options[]=pagebreaks'); + + $response->assertOk(); + $this->assertSame(2, $this->countPdfPages($response->getContent()), 'Expected one page per day (2).'); + } + + #[Test] + public function region_day_page_breaks_do_not_emit_blank_pages_on_the_chunked_path(): void + { + Http::fake(['example.test/large' => Http::response($this->largeMultiRegionFeed(60), 200)]); + + $response = $this->get('/pdf?json=https://example.test/large&group_by=region-day&options[]=pagebreaks'); + + $response->assertOk(); + $this->assertSame( + 60, + $this->countPdfPages($response->getContent()), + 'Expected one page per region (60). Feeds over 500 meetings render each region as a separate ' + .'PDF and merge with FPDI; without a .region:last-child suppressor every chunk ends with a ' + .'trailing blank page, which the merge preserves — 120 pages, half of them blank.' + ); + } + + /** + * A feed large enough to exceed the 500-meeting fast path in Controller::pdf() + * and exercise the chunked render + FPDI merge instead. + * + * @return list> + */ + protected function largeMultiRegionFeed(int $regions): array + { + $meetings = []; + + foreach (range(1, $regions) as $r) { + $region = 'Region'.str_pad((string) $r, 2, '0', STR_PAD_LEFT); + + foreach ([1, 3, 5] as $day) { + for ($i = 0; $i < 3; $i++) { + $meetings[] = [ + 'slug' => strtolower($region)."-$day-$i", + 'name' => $region.' AA Group '.$i, + 'day' => $day, + 'time' => '19:00', + 'address' => (100 + $i).' Main St, '.$region.', MS', + 'regions' => ['Mississippi', 'North', $region], + 'types' => ['O'], + ]; + } + } + } + + return $meetings; + } + + protected function fakeMultiRegionFeed(): void + { + Http::fake([ + 'example.test/regions' => Http::response( + json_decode(file_get_contents(base_path('tests/Fixtures/meetings-multi-region.json')), true), + 200 + ), + ]); + } + + protected function countPdfPages(string $pdf): int + { + return preg_match_all('~/Type\s*/Page[^s]~', $pdf); + } + #[Test] public function pdf_generates_from_google_sheets_url(): void { diff --git a/tests/Fixtures/meetings-multi-region.json b/tests/Fixtures/meetings-multi-region.json new file mode 100644 index 0000000..a135a58 --- /dev/null +++ b/tests/Fixtures/meetings-multi-region.json @@ -0,0 +1,158 @@ +[ + { + "slug": "alpha-0-0", + "name": "Alpha Meeting 00", + "day": 0, + "time": "19:00", + "address": "100 Main St, Alpha, MS", + "regions": [ + "Alpha" + ], + "types": [ + "O" + ] + }, + { + "slug": "alpha-0-1", + "name": "Alpha Meeting 01", + "day": 0, + "time": "19:00", + "address": "101 Main St, Alpha, MS", + "regions": [ + "Alpha" + ], + "types": [ + "O" + ] + }, + { + "slug": "alpha-1-0", + "name": "Alpha Meeting 10", + "day": 1, + "time": "19:00", + "address": "100 Main St, Alpha, MS", + "regions": [ + "Alpha" + ], + "types": [ + "O" + ] + }, + { + "slug": "alpha-1-1", + "name": "Alpha Meeting 11", + "day": 1, + "time": "19:00", + "address": "101 Main St, Alpha, MS", + "regions": [ + "Alpha" + ], + "types": [ + "O" + ] + }, + { + "slug": "bravo-0-0", + "name": "Bravo Meeting 00", + "day": 0, + "time": "19:00", + "address": "100 Main St, Bravo, MS", + "regions": [ + "Bravo" + ], + "types": [ + "O" + ] + }, + { + "slug": "bravo-0-1", + "name": "Bravo Meeting 01", + "day": 0, + "time": "19:00", + "address": "101 Main St, Bravo, MS", + "regions": [ + "Bravo" + ], + "types": [ + "O" + ] + }, + { + "slug": "bravo-1-0", + "name": "Bravo Meeting 10", + "day": 1, + "time": "19:00", + "address": "100 Main St, Bravo, MS", + "regions": [ + "Bravo" + ], + "types": [ + "O" + ] + }, + { + "slug": "bravo-1-1", + "name": "Bravo Meeting 11", + "day": 1, + "time": "19:00", + "address": "101 Main St, Bravo, MS", + "regions": [ + "Bravo" + ], + "types": [ + "O" + ] + }, + { + "slug": "charlie-0-0", + "name": "Charlie Meeting 00", + "day": 0, + "time": "19:00", + "address": "100 Main St, Charlie, MS", + "regions": [ + "Charlie" + ], + "types": [ + "O" + ] + }, + { + "slug": "charlie-0-1", + "name": "Charlie Meeting 01", + "day": 0, + "time": "19:00", + "address": "101 Main St, Charlie, MS", + "regions": [ + "Charlie" + ], + "types": [ + "O" + ] + }, + { + "slug": "charlie-1-0", + "name": "Charlie Meeting 10", + "day": 1, + "time": "19:00", + "address": "100 Main St, Charlie, MS", + "regions": [ + "Charlie" + ], + "types": [ + "O" + ] + }, + { + "slug": "charlie-1-1", + "name": "Charlie Meeting 11", + "day": 1, + "time": "19:00", + "address": "101 Main St, Charlie, MS", + "regions": [ + "Charlie" + ], + "types": [ + "O" + ] + } +] \ No newline at end of file From 3ff0c3421b8464e6f24712bd930d9fc45edb6fc4 Mon Sep 17 00:00:00 2001 From: Anthony Baggett Date: Sun, 2 Aug 2026 10:51:47 -0500 Subject: [PATCH 2/2] Omit the region heading for meetings with no region in region-day mode region-day rendered `{{ $region }}` unconditionally, so a feed containing meetings with no region produced an empty heading for that group. The span carries `border-bottom: 0.5px solid black`, so it printed as a stray horizontal rule above content with no title -- visible as the untitled first group in large directories. day-region already guards its label with `@if ($region)`, as does the nested day subheading directly below. This mirrors that guard. Adds a regression test rendering the template with a region-less group. --- resources/views/pdf.blade.php | 4 +++- tests/Feature/PdfTest.php | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/resources/views/pdf.blade.php b/resources/views/pdf.blade.php index c34d10e..54a0acd 100644 --- a/resources/views/pdf.blade.php +++ b/resources/views/pdf.blade.php @@ -164,7 +164,9 @@ @elseif ($group_by === 'region-day') @foreach ($regions as $region => $days)
- {{ $region }} + @if ($region) + {{ $region }} + @endif @foreach ($days as $day => $meetings)
@if ($day) diff --git a/tests/Feature/PdfTest.php b/tests/Feature/PdfTest.php index 7e33156..2a17d76 100644 --- a/tests/Feature/PdfTest.php +++ b/tests/Feature/PdfTest.php @@ -133,6 +133,39 @@ protected function largeMultiRegionFeed(int $regions): array return $meetings; } + #[Test] + public function region_day_omits_the_heading_for_meetings_with_no_region(): void + { + $meeting = (object) [ + 'time_formatted' => '7 am', + 'name' => 'Sun Up Group', + 'location' => null, + 'address' => '127 Front St', + 'regions_formatted' => '', + 'types' => ['O'], + ]; + + $html = view('pdf', [ + 'language' => 'en', + 'font' => 'Noto Sans', + 'font_size' => 10, + 'numbering' => false, + 'group_by' => 'region-day', + 'types_in_use' => [], + 'types' => [], + 'options' => [], + 'meeting_types_heading' => 'Meeting Types', + 'days' => collect(), + 'regions' => collect(['' => collect(['MONDAY' => collect([$meeting])])]), + ])->render(); + + $this->assertStringNotContainsString( + '', + preg_replace('~\s*~', '', $html), + 'A region-less group must not emit an empty .heading — its border-bottom renders as a stray rule.' + ); + } + protected function fakeMultiRegionFeed(): void { Http::fake([