diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index ce03819e9..4f79d4ea5 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -912,3 +912,122 @@ 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') + const isOn = () => toggle()!.getAttribute('aria-checked') === 'true' + + 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(isOn()).toBe(true) + }) + + it('FE-PDF-BREAK-001b: the control is the switch from the rest of the app, not a checkbox', async () => { + await downloadTripPDF(twoDays) + const el = toggle()! + + expect(el.tagName).toBe('BUTTON') + expect(el.getAttribute('role')).toBe('switch') + expect(el.style.background).toContain('--accent') + expect(document.querySelector('#pdf-daybreak-toggle input')).toBeNull() + }) + + 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(isOn()).toBe(false) + // Off uses the neutral track, so the two states are told apart by more than the knob. + expect(toggle()!.style.background).toContain('--border-primary') + }) + + 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) + toggle()!.click() + + expect(isOn()).toBe(false) + 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) + toggle()!.click() + + expect(isOn()).toBe(true) + 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"') + + expect(() => toggle()!.click()).not.toThrow() + }) + + // Verified against a real print engine: without this rule Chromium tore a stay + // across the page edge, the check-in time on one sheet and the hotel name on + // the next. Place and note cards already carried it. + it('FE-PDF-BREAK-007: a stay may not be split by a page edge', async () => { + await downloadTripPDF(twoDays) + const html = getIframe()!.srcdoc + const rule = html.slice(html.indexOf('.day-accommodation {')) + expect(rule.slice(0, rule.indexOf('}'))).toContain('break-inside: avoid') + }) + + // Measured against Chromium: with the days flowing, a header bar could be left + // at the foot of a sheet while its content moved on, and the repeat from #1471 + // then read as the same day printed twice. break-after on the thead and + // break-before on the tbody are both ignored; holding the day together is what + // works, and a day too long for one page still breaks and still repeats. + it('FE-PDF-BREAK-008: a flowing day is held together so its header is never stranded', async () => { + localStorage.setItem('trek_pdf_page_break_per_day', '0') + await downloadTripPDF(twoDays) + const html = getIframe()!.srcdoc + + expect(html).toContain('.pdf-flow .day-section { break-inside: avoid; page-break-inside: avoid; }') + // Scoped: a day that starts its own page cannot strand a header. + const at = html.indexOf('.day-section { break-inside: avoid') + expect(html.slice(at - 10, at)).toContain('.pdf-flow ') + }) +}) diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index a39515fb6..1f2856651 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -9,6 +9,37 @@ 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. + } +} + +/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is + rebuilt here rather than rendered — as static markup it would carry no + behaviour. Same geometry and the same tokens, so it reads as the same control. */ +const TOGGLE_TRACK = 'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;' +const TOGGLE_KNOB = 'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);' +const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)') +const knobOffset = (on: boolean) => (on ? '22px' : '2px') + function renderLucideIcon(icon:LucideIcon, props = {}) { if (!_renderToStaticMarkup) return '' return _renderToStaticMarkup( @@ -150,6 +181,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 +435,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 +537,20 @@ 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; } + /* Hold a flowing day together. Without this the header bar can be placed at the + foot of a sheet while its content moves to the next one, which then repeats + the header (#1471) and reads as the same day printed twice. A day too long + for one page still breaks and still repeats its header — the engine only + honours this where it can. Days that start a page of their own cannot strand + a header, so it is scoped to the flowing layout. + Measured, not assumed: break-after on the thead and break-before on the tbody + are both ignored by Chromium here. */ + .pdf-flow .day-section { break-inside: avoid; page-break-inside: avoid; } .day-section { width: 100%; border-collapse: collapse; table-layout: fixed; } .day-header-bar { background: #0f172a; padding: 11px 28px; @@ -528,6 +574,10 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca flex: 1 1 45%; min-width: 200px; margin: 4px 0; padding: 10px; border: 2px solid #e2e8f0; border-radius: 12px; display: flex; flex-direction: column; + /* Place and note cards have said this all along; without it a stay is torn + in half by a page edge, its check-in time on one sheet and the hotel name + on the next. Rare while every day started a page, common once they flow. */ + break-inside: avoid; page-break-inside: avoid; } .day-accommodation-title { font-size: 16px; font-weight: 600; text-align: center; @@ -608,7 +658,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca } - +