From 03be438e0b996b12428e7f4438d34966dbc5230a Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 29 Jul 2026 19:38:27 +0200 Subject: [PATCH] feat(pdf): make the page break between days optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every day started a new page, so a plan of short days printed one sheet per handful of lines and looked nothing like the view it was exported from. The preview now carries the choice, next to Save as PDF, and remembers it. The two layouts differ by a single class on , so ticking the box re-lays the open preview out immediately instead of rebuilding the document and re-fetching every photo. Breaking per day stays the default — this only adds the other option. --- client/src/components/PDF/TripPDF.test.ts | 84 +++++++++++++++++++++++ client/src/components/PDF/TripPDF.tsx | 49 +++++++++++-- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index ce03819e9..2c8417e9a 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -912,3 +912,87 @@ describe('downloadTripPDF defaults', () => { expect(srcdoc()).toContain('Day 1') }) }) + +// #1292 — every day started a new page, so a plan of short days printed one +// sheet per handful of lines. The flowing layout is opt-in and remembered. +describe('page breaks between days (#1292)', () => { + const twoDays = { + ...minimalArgs, + days: [ + { id: 1, day_number: 1, title: 'First', date: '2025-06-01' }, + { id: 2, day_number: 2, title: 'Second', date: '2025-06-02' }, + ] as any[], + } + + beforeEach(() => { + localStorage.removeItem('trek_pdf_page_break_per_day') + }) + + const toggle = () => + document.querySelector('#pdf-daybreak-toggle input') + + it('FE-PDF-BREAK-001: days break onto their own page by default', async () => { + await downloadTripPDF(twoDays) + const html = getIframe()!.srcdoc + + expect(html).toContain('day-section day-break') + expect(html).not.toContain('class="pdf-flow"') + expect(toggle()!.checked).toBe(true) + }) + + it('FE-PDF-BREAK-002: the remembered flowing layout comes back on the next export', async () => { + localStorage.setItem('trek_pdf_page_break_per_day', '0') + await downloadTripPDF(twoDays) + const html = getIframe()!.srcdoc + + // The class stays on the day; the body is what decides whether it breaks. + expect(html).toContain('day-section day-break') + expect(html).toContain('') + expect(toggle()!.checked).toBe(false) + }) + + it('FE-PDF-BREAK-003: the first day never carries a break of its own', async () => { + await downloadTripPDF(twoDays) + const html = getIframe()!.srcdoc + + expect(html.indexOf('')).toBeGreaterThan(-1) + expect(html.match(/day-section day-break/g)).toHaveLength(1) + }) + + it('FE-PDF-BREAK-004: turning the breaks off is remembered and shown at once', async () => { + await downloadTripPDF(twoDays) + const box = toggle()! + box.checked = false + box.dispatchEvent(new Event('change')) + + expect(localStorage.getItem('trek_pdf_page_break_per_day')).toBe('0') + const body = getIframe()!.contentDocument?.body + if (body) expect(body.classList.contains('pdf-flow')).toBe(true) + }) + + it('FE-PDF-BREAK-005: turning them back on is remembered too', async () => { + localStorage.setItem('trek_pdf_page_break_per_day', '0') + await downloadTripPDF(twoDays) + const box = toggle()! + box.checked = true + box.dispatchEvent(new Event('change')) + + expect(localStorage.getItem('trek_pdf_page_break_per_day')).toBe('1') + const body = getIframe()!.contentDocument?.body + if (body) expect(body.classList.contains('pdf-flow')).toBe(false) + }) + + it('FE-PDF-BREAK-006: a storage the browser blocks costs the preference, not the export', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked') }) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked') }) + + await downloadTripPDF(twoDays) + const html = getIframe()!.srcdoc + expect(html).toContain('day-section day-break') + expect(html).not.toContain('class="pdf-flow"') + + const box = toggle()! + box.checked = false + expect(() => box.dispatchEvent(new Event('change'))).not.toThrow() + }) +}) diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index a39515fb6..3f29a400c 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -9,6 +9,29 @@ import { formatMoney, formatMoneySum, splitReservationDateTime, type MoneyEntry import { fetchExchangeRates } from '../../hooks/useExchangeRates' import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs' +/** + * Every day starts a new page by default. On a trip of short days that prints + * one sheet per handful of lines, so the preview offers the flowing layout the + * in-app view already uses, and remembers which one was picked (#1292). + */ +const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day' + +function pageBreakPerDay(): boolean { + try { + return localStorage.getItem(PAGE_BREAK_KEY) !== '0' + } catch { + return true + } +} + +function rememberPageBreakPerDay(on: boolean): void { + try { + localStorage.setItem(PAGE_BREAK_KEY, on ? '1' : '0') + } catch { + // A blocked localStorage costs the preference, not the export. + } +} + function renderLucideIcon(icon:LucideIcon, props = {}) { if (!_renderToStaticMarkup) return '' return _renderToStaticMarkup( @@ -150,6 +173,7 @@ interface downloadTripPDFProps { // relies on it being an object. export async function downloadTripPDF({ trip, days, places, assignments = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) { await ensureRenderer() + const breaksPerDay = pageBreakPerDay() const loc = _locale || undefined const tr = _t || (k => k) const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number) @@ -403,7 +427,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca // every page an overflowing day spills onto (#1471). CSS `table-header-group` // on a
is NOT repeated by Chromium's print engine — only real thead is. return ` -
+
${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()} @@ -505,6 +529,11 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca /* ── Day ───────────────────────────────────────── */ /* .day-section is a real ; its day header repeats on overflow pages. */ .page-break { page-break-before: always; } + /* Days break by default; .pdf-flow on is the toggle in the preview (#1292). + Flowing days butt against each other without the page edge between them. */ + .day-break { page-break-before: always; } + .pdf-flow .day-break { page-break-before: auto; } + .pdf-flow .day-section + .day-section { margin-top: 18px; } .day-section { width: 100%; border-collapse: collapse; table-layout: fixed; } .day-header-bar { background: #0f172a; padding: 11px 28px; @@ -608,7 +637,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca } - +