diff --git a/client/src/components/Planner/TripExportModal.tsx b/client/src/components/Planner/TripExportModal.tsx index 6ebf58ed4..a280d40c0 100644 --- a/client/src/components/Planner/TripExportModal.tsx +++ b/client/src/components/Planner/TripExportModal.tsx @@ -6,11 +6,7 @@ import { IcsSubscribeModal } from './IcsSubscribeModal' import { SoftPill } from './planParts' import { useToast } from '../shared/Toast' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' -import { useSettingsStore } from '../../store/settingsStore' -import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' -import { useAuthStore } from '../../store/authStore' -import { hasPersonalPlan } from '../PDF/pdfScope' -import { importChunk } from '../../utils/chunkReload' +import { useTripExport } from './useTripExport' /** * What a GPX download can carry. Worded by what someone wants on their device @@ -60,88 +56,21 @@ export function TripExportModal({ t, locale, toast, canManageShare = true, }: TripExportModalProps) { const [subscribeOpen, setSubscribeOpen] = useState(false) - // Which row is working, so the dialog can say so instead of looking inert - // while a 226 kB PDF builder is fetched and a document is rendered. - const [busy, setBusy] = useState(null) - // The PDF is built outside React, so it cannot read this itself (#2066). - const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' - const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - // The export gets the store's assignments and applies the day plan's own filter to - // them, so it needs the same switch the plan reads. - const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, tripId) const titleId = useId() - const fileBase = trip?.title || 'trip' - - // Shared tail of every download: Firefox and Safari cancel the download when - // the object URL is revoked before they picked the blob up, hence the delay. - const saveBlob = (blob: Blob, filename: string) => { - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100) - } - - const myId = useAuthStore(s => s.user?.id) - // "My plan" (#2168) only once somebody has been given a part of the trip. - const offerMine = myId != null && hasPersonalPlan(assignments, reservations) - - const exportPdf = async (mine = false) => { - if (busy) return - setBusy(mine ? 'pdf:mine' : 'pdf') - const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) => - notes.map(n => ({ ...n, day_id: Number(dayId) })), - ) - try { - // Loaded on click: the PDF builder is ~226 kB and hangs off the days - // sidebar, so every trip used to pay for it whether or not anyone - // exported. A missing chunk lands in the catch and shows the same error - // the export already had. - const { downloadTripPDF } = await importChunk(() => import('../PDF/TripPDF')) - await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit, showServiceStops, onlyUserId: mine ? myId : undefined }) - onClose() - } catch (e) { - console.error('PDF error:', e) - toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`) - } finally { - setBusy(null) - } - } - - const downloadIcs = async () => { - if (busy) return - setBusy('ics') - try { - const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' }) - if (!res.ok) throw new Error() - saveBlob(await res.blob(), `${fileBase}.ics`) - onClose() - } catch { - toast.error(t('planner.icsExportFailed')) - } finally { - setBusy(null) - } - } - - const downloadGpx = async (key: string, query: string) => { - if (busy) return - setBusy(`gpx:${key}`) - try { - const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' }) - // 404 here means the selection is empty, which is worth its own message: - // "nothing happened" and "the download broke" look identical otherwise. - if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return } - if (!res.ok) throw new Error() - saveBlob(await res.blob(), `${fileBase}.gpx`) - onClose() - } catch { - toast.error(t('dayplan.gpxFailed')) - } finally { - setBusy(null) - } - } + const { + fileBase, offerMine, isRunning, anyRunning, exportPdf, downloadIcs, downloadGpx, + } = useTripExport({ + tripId, + data: { trip, days, places, assignments, categories, reservations, dayNotes }, + t, + locale, + toast, + exclusive: true, + onExported: onClose, + requireTrip: false, + closeAfterPdf: true, + logPdfErrors: true, + }) const header = ( exportPdf()} /> {offerMine && ( @@ -175,8 +104,8 @@ export function TripExportModal({ icon={UserRound} title={t('dayplan.pdfMine')} sub={t('dayplan.pdfMineSub')} - busy={busy === 'pdf:mine'} - disabled={busy != null} + busy={isRunning('pdf:mine')} + disabled={anyRunning} onClick={() => exportPdf(true)} /> )} @@ -189,8 +118,8 @@ export function TripExportModal({ icon={CalendarDays} title={t('mobileTrip.icsDownload')} sub={`${fileBase}.ics`} - busy={busy === 'ics'} - disabled={busy != null} + busy={isRunning('ics')} + disabled={anyRunning} onClick={downloadIcs} /> {canManageShare && ( @@ -198,7 +127,7 @@ export function TripExportModal({ icon={CalendarPlus} title={t('mobileTrip.icsSubscribe')} sub={t('mobileTrip.icsSubscribeSub')} - disabled={busy != null} + disabled={anyRunning} onClick={() => setSubscribeOpen(true)} /> )} @@ -215,8 +144,8 @@ export function TripExportModal({ key={scope.key} icon={scope.icon} title={t(scope.labelKey)} - busy={busy === `gpx:${scope.key}`} - disabled={busy != null} + busy={isRunning(`gpx:${scope.key}`)} + disabled={anyRunning} onClick={() => downloadGpx(scope.key, scope.query)} /> ))} diff --git a/client/src/components/Planner/useTripExport.test.ts b/client/src/components/Planner/useTripExport.test.ts new file mode 100644 index 000000000..53477e331 --- /dev/null +++ b/client/src/components/Planner/useTripExport.test.ts @@ -0,0 +1,241 @@ +// FE-PLANNER-TRIPEXPORT-001 to -010: the trip exports behind the desktop export +// dialog and the phone's export sheet. +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; + +import { buildAssignment, buildReservation, buildTrip, buildUser } from '../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useAuthStore } from '../../store/authStore'; +import type { DayNote } from '../../types'; +import { downloadTripPDF } from '../PDF/TripPDF'; +import { flatDayNotes, saveBlob, useTripExport, type TripExportOptions } from './useTripExport'; + +vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn() })); + +const t = (key: string) => key; +const trip = buildTrip({ id: 3, title: 'Alps' }); +const NOTES = { '7': [{ id: 1, text: 'Pack' }] as unknown as DayNote[] }; + +function options(overrides: Partial = {}): TripExportOptions { + return { + tripId: 3, + data: { trip, days: [], places: [], assignments: {}, categories: [], reservations: [], dayNotes: NOTES }, + t, + locale: 'en-US', + toast: { error: vi.fn(), info: vi.fn() }, + exclusive: true, + onExported: vi.fn(), + requireTrip: false, + closeAfterPdf: true, + logPdfErrors: true, + ...overrides, + }; +} + +/** How the phone sheet runs the hook. */ +const PHONE: Partial = { + exclusive: false, + requireTrip: true, + closeAfterPdf: false, + logPdfErrors: false, +}; + +const response = (status: number) => + ({ ok: status >= 200 && status < 300, status, blob: async () => new Blob(['x']) }) as unknown as Response; + +/** A promise the test settles by hand, to look at the hook while an export runs. */ +function deferred() { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +let fetchSpy: MockInstance; +let clickSpy: MockInstance<() => void>; + +beforeEach(() => { + resetAllStores(); + vi.mocked(downloadTripPDF).mockReset().mockResolvedValue(undefined); + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response(200)); + clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:trek/file'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('flatDayNotes and saveBlob', () => { + it('FE-PLANNER-TRIPEXPORT-001: the day notes come as one list that names each day', () => { + expect(flatDayNotes({ '2': [{ id: 1 }, { id: 2 }] as DayNote[], '5': [{ id: 3 }] as DayNote[] })).toEqual([ + { id: 1, day_id: 2 }, + { id: 2, day_id: 2 }, + { id: 3, day_id: 5 }, + ]); + }); + + it('FE-PLANNER-TRIPEXPORT-002: a file is handed over under its name and its URL let go a moment later', () => { + vi.useFakeTimers(); + saveBlob(new Blob(['x']), 'Alps.ics'); + const anchor = clickSpy.mock.contexts[0] as HTMLAnchorElement; + expect(anchor.download).toBe('Alps.ics'); + expect(anchor.getAttribute('href')).toBe('blob:trek/file'); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + vi.advanceTimersByTime(100); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:trek/file'); + expect(document.body.contains(anchor)).toBe(false); + }); +}); + +describe('useTripExport', () => { + it('FE-PLANNER-TRIPEXPORT-003: names the files after the trip and offers my plan once somebody has a part', () => { + expect(renderHook(() => useTripExport(options())).result.current).toMatchObject({ + fileBase: 'Alps', + offerMine: false, + }); + const untitled = options({ data: { ...options().data, trip: null } }); + expect(renderHook(() => useTripExport(untitled)).result.current.fileBase).toBe('trip'); + + seedStore(useAuthStore, { user: buildUser({ id: 9 }) }); + const shared = options({ + data: { ...options().data, reservations: [buildReservation({ travelers: [{ user_id: 9, username: 'me' }] })] }, + }); + expect(renderHook(() => useTripExport(shared)).result.current.offerMine).toBe(true); + const assigned = options({ + data: { + ...options().data, + assignments: { '1': [buildAssignment({ participants: [{ user_id: 9, username: 'me' }] })] }, + }, + }); + expect(renderHook(() => useTripExport(assigned)).result.current.offerMine).toBe(true); + }); + + it('FE-PLANNER-TRIPEXPORT-004: the PDF gets the whole plan, or only mine, and the desktop closes once it is out', async () => { + seedStore(useAuthStore, { user: buildUser({ id: 9 }) }); + const opts = options(); + const { result } = renderHook(() => useTripExport(opts)); + await act(() => result.current.exportPdf()); + expect(downloadTripPDF).toHaveBeenCalledWith( + expect.objectContaining({ + trip, + dayNotes: [{ id: 1, text: 'Pack', day_id: 7 }], + locale: 'en-US', + onlyUserId: undefined, + }) + ); + expect(opts.onExported).toHaveBeenCalledTimes(1); + await act(() => result.current.exportPdf(true)); + expect(vi.mocked(downloadTripPDF).mock.calls[1][0]).toMatchObject({ onlyUserId: 9 }); + }); + + it('FE-PLANNER-TRIPEXPORT-005: the phone stays open after the PDF and prints nothing without a trip', async () => { + const opts = options(PHONE); + const { result } = renderHook(() => useTripExport(opts)); + await act(() => result.current.exportPdf()); + expect(downloadTripPDF).toHaveBeenCalledTimes(1); + expect(opts.onExported).not.toHaveBeenCalled(); + + const noTrip = renderHook(() => useTripExport({ ...opts, data: { ...opts.data, trip: null } })).result; + await act(() => noTrip.current.exportPdf()); + expect(downloadTripPDF).toHaveBeenCalledTimes(1); + }); + + it('FE-PLANNER-TRIPEXPORT-006: a failed PDF says why; only the desktop also logs it', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(downloadTripPDF).mockRejectedValue(new Error('font missing')); + const desktop = options(); + const d = renderHook(() => useTripExport(desktop)).result; + await act(() => d.current.exportPdf()); + expect(desktop.toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'); + expect(desktop.onExported).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledTimes(1); + + vi.mocked(downloadTripPDF).mockRejectedValue('renderer gone'); + const phone = options(PHONE); + const p = renderHook(() => useTripExport(phone)).result; + await act(() => p.current.exportPdf()); + expect(phone.toast.error).toHaveBeenCalledWith('dayplan.pdfError: renderer gone'); + expect(log).toHaveBeenCalledTimes(1); + }); + + it('FE-PLANNER-TRIPEXPORT-007: on the desktop one running export holds every other', async () => { + const pdf = deferred(); + vi.mocked(downloadTripPDF).mockReturnValue(pdf.promise); + const { result } = renderHook(() => useTripExport(options())); + let running!: Promise; + act(() => { + running = result.current.exportPdf(); + }); + await vi.waitFor(() => expect(downloadTripPDF).toHaveBeenCalled()); + expect(result.current.isRunning('pdf')).toBe(true); + expect(result.current.isRunning('pdf:mine')).toBe(false); + expect(result.current.anyRunning).toBe(true); + await act(() => result.current.downloadIcs()); + expect(fetchSpy).not.toHaveBeenCalled(); + await act(async () => { + pdf.resolve(); + await running; + }); + expect(result.current.anyRunning).toBe(false); + }); + + it('FE-PLANNER-TRIPEXPORT-008: on the phone a running PDF holds only another PDF', async () => { + const pdf = deferred(); + vi.mocked(downloadTripPDF).mockReturnValue(pdf.promise); + const opts = options(PHONE); + const { result } = renderHook(() => useTripExport(opts)); + let running!: Promise; + act(() => { + running = result.current.exportPdf(); + }); + await vi.waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1)); + await act(() => result.current.exportPdf(true)); + expect(downloadTripPDF).toHaveBeenCalledTimes(1); + await act(() => result.current.downloadIcs()); + expect(fetchSpy).toHaveBeenCalledWith('/api/trips/3/export.ics', { credentials: 'include' }); + expect(opts.onExported).toHaveBeenCalledTimes(1); + expect(result.current.isRunning('pdf')).toBe(true); + await act(async () => { + pdf.resolve(); + await running; + }); + expect(result.current.isRunning('pdf')).toBe(false); + }); + + it('FE-PLANNER-TRIPEXPORT-009: the calendar file is saved under the trip title, and a refusal says so', async () => { + const opts = options(); + const { result } = renderHook(() => useTripExport(opts)); + await act(() => result.current.downloadIcs()); + expect((clickSpy.mock.contexts[0] as HTMLAnchorElement).download).toBe('Alps.ics'); + expect(opts.onExported).toHaveBeenCalledTimes(1); + + fetchSpy.mockResolvedValue(response(500)); + await act(() => result.current.downloadIcs()); + expect(opts.toast.error).toHaveBeenCalledWith('planner.icsExportFailed'); + expect(opts.onExported).toHaveBeenCalledTimes(1); + }); + + it('FE-PLANNER-TRIPEXPORT-010: a GPX scope asks for its own query; an empty one and a broken one say so apart', async () => { + const opts = options(); + const { result } = renderHook(() => useTripExport(opts)); + await act(() => result.current.downloadGpx('places', '?dayRoutes=false')); + expect(fetchSpy).toHaveBeenCalledWith('/api/trips/3/places/export.gpx?dayRoutes=false', { credentials: 'include' }); + expect((clickSpy.mock.contexts[0] as HTMLAnchorElement).download).toBe('Alps.gpx'); + expect(opts.onExported).toHaveBeenCalledTimes(1); + + fetchSpy.mockResolvedValue(response(404)); + await act(() => result.current.downloadGpx('all', '')); + expect(opts.toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'); + expect(opts.toast.error).not.toHaveBeenCalled(); + + fetchSpy.mockRejectedValue(new Error('offline')); + await act(() => result.current.downloadGpx('all', '')); + expect(opts.toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'); + expect(opts.onExported).toHaveBeenCalledTimes(1); + expect(result.current.anyRunning).toBe(false); + }); +}); diff --git a/client/src/components/Planner/useTripExport.ts b/client/src/components/Planner/useTripExport.ts new file mode 100644 index 000000000..0b1fabbc0 --- /dev/null +++ b/client/src/components/Planner/useTripExport.ts @@ -0,0 +1,193 @@ +import { useState } from 'react'; + +import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import type { AssignmentsMap, Category, Day, DayNote, Place, Reservation, Trip } from '../../types'; +import { importChunk } from '../../utils/chunkReload'; +import { hasPersonalPlan } from '../PDF/pdfScope'; + +type Translate = (key: string, params?: Record) => string; + +/** A running export: a PDF of the plan or of my plan, the calendar file, or a GPX scope. */ +export type ExportJob = 'pdf' | 'pdf:mine' | 'ics' | `gpx:${string}`; + +/** What a trip export prints, as both shells hold it. */ +export interface TripExportData { + trip: Trip | null; + days: Day[]; + places: Place[]; + assignments: AssignmentsMap; + categories: Category[]; + reservations: Reservation[]; + dayNotes: Record; +} + +export interface TripExportOptions { + tripId: number; + data: TripExportData; + t: Translate; + locale: string; + toast: { error: (message: string) => void; info: (message: string) => void }; + /** + * The desktop runs one export at a time, so any running one holds every row; the + * phone holds back only another PDF, calendar file or GPX file while the same kind + * still runs. + */ + exclusive: boolean; + /** Runs once a calendar or GPX file is saved, and once the PDF is out when closeAfterPdf is set. */ + onExported: () => void; + /** The phone prints no PDF until the trip has loaded; the desktop hands the PDF builder what it has. */ + requireTrip: boolean; + /** The desktop closes after the PDF as well; the phone sheet stays open. */ + closeAfterPdf: boolean; + /** The desktop also logs a failed PDF to the console. */ + logPdfErrors: boolean; +} + +/** + * Hands the browser a downloaded file. Firefox and Safari cancel the download when + * the object URL is revoked before they picked the blob up, hence the delay. + */ +export function saveBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + URL.revokeObjectURL(url); + a.remove(); + }, 100); +} + +/** The day notes as one list, each carrying the day it belongs to. */ +export function flatDayNotes(dayNotes: Record) { + return Object.entries(dayNotes).flatMap(([dayId, notes]) => notes.map((n) => ({ ...n, day_id: Number(dayId) }))); +} + +/** + * Every way a trip leaves TREK, behind the desktop export dialog and the phone's + * export sheet: the day plan as a PDF (the whole plan, or my plan once somebody + * has been given a part of the trip, #2168), the bookings as an .ics file and the + * map data as GPX. + */ +export function useTripExport({ + tripId, + data, + t, + locale, + toast, + exclusive, + onExported, + requireTrip, + closeAfterPdf, + logPdfErrors, +}: TripExportOptions) { + // Which export is working, per lane, so a row can say so instead of looking inert + // while a 226 kB PDF builder is fetched and a document is rendered. + const [running, setRunning] = useState>({}); + // The PDF is built outside React, so it cannot read these itself (#2066). + const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'; + const distanceUnit = useSettingsStore((s) => s.settings.distance_unit); + // The export gets the store's assignments and applies the day plan's own filter to + // them, so it needs the same switch the plan reads. + const showServiceStops = useRoadtripSettings((s) => s.roadtrip_service_stops_in_days !== false, tripId); + const myId = useAuthStore((s) => s.user?.id); + const offerMine = myId != null && hasPersonalPlan(data.assignments, data.reservations); + const fileBase = data.trip?.title || 'trip'; + + const laneOf = (job: ExportJob) => (exclusive ? 'all' : job.split(':')[0]); + const isRunning = (job: ExportJob) => running[laneOf(job)] === job; + const begin = (job: ExportJob) => setRunning((prev) => ({ ...prev, [laneOf(job)]: job })); + const end = (job: ExportJob) => + setRunning((prev) => { + const next = { ...prev }; + delete next[laneOf(job)]; + return next; + }); + + const exportPdf = async (mine = false) => { + const job: ExportJob = mine ? 'pdf:mine' : 'pdf'; + if ((requireTrip && !data.trip) || running[laneOf(job)]) return; + begin(job); + const flatNotes = flatDayNotes(data.dayNotes); + try { + // Loaded on click: the PDF builder is ~226 kB, so every trip used to pay for it + // whether or not anyone exported. A missing chunk lands in the catch and shows + // the same error the export already had. + const { downloadTripPDF } = await importChunk(() => import('../PDF/TripPDF')); + await downloadTripPDF({ + trip: data.trip, + days: data.days, + places: data.places, + assignments: data.assignments, + categories: data.categories, + dayNotes: flatNotes, + reservations: data.reservations, + t, + locale, + timeFormat, + distanceUnit, + showServiceStops, + onlyUserId: mine ? myId : undefined, + }); + if (closeAfterPdf) onExported(); + } catch (e) { + if (logPdfErrors) console.error('PDF error:', e); + toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`); + } finally { + end(job); + } + }; + + const downloadIcs = async () => { + const job: ExportJob = 'ics'; + if (running[laneOf(job)]) return; + begin(job); + try { + const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' }); + if (!res.ok) throw new Error(); + saveBlob(await res.blob(), `${fileBase}.ics`); + onExported(); + } catch { + toast.error(t('planner.icsExportFailed')); + } finally { + end(job); + } + }; + + /** One GPX scope; `query` narrows what the file carries, empty for everything. */ + const downloadGpx = async (key: string, query: string) => { + const job: ExportJob = `gpx:${key}`; + if (running[laneOf(job)]) return; + begin(job); + try { + const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' }); + // 404 here means the selection is empty, which is worth its own message: + // "nothing happened" and "the download broke" look identical otherwise. + if (res.status === 404) { + toast.info(t('dayplan.gpxEmpty')); + return; + } + if (!res.ok) throw new Error(); + saveBlob(await res.blob(), `${fileBase}.gpx`); + onExported(); + } catch { + toast.error(t('dayplan.gpxFailed')); + } finally { + end(job); + } + }; + + return { + fileBase, + offerMine, + isRunning, + anyRunning: Object.keys(running).length > 0, + exportPdf, + downloadIcs, + downloadGpx, + }; +} diff --git a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx index 054f0e119..b96ee8555 100644 --- a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx @@ -3,15 +3,11 @@ import { CalendarPlus, ChevronRight, FileDown, Share2, UserRound } from 'lucide- import MSheet from '../../../components/MSheet' import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeModal' import { useTripStore } from '../../../../store/tripStore' -import { useSettingsStore } from '../../../../store/settingsStore' -import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings' import { useTranslation } from '../../../../i18n' -import { useAuthStore } from '../../../../store/authStore' -import { hasPersonalPlan } from '../../../../components/PDF/pdfScope' +import { useTripExport } from '../../../../components/Planner/useTripExport' import { INNER_CLS, TileHeader } from './MTripSheetUi' import type { MTripSheetsProps } from '../MTripShell' import type { LucideIcon } from 'lucide-react' -import { importChunk } from '../../../../utils/chunkReload' /** * Export sheet ('export', opened from the Mehr sheet): the desktop day-plan @@ -21,106 +17,40 @@ import { importChunk } from '../../../../utils/chunkReload' */ export default function MExportSheet({ planner, shell }: MTripSheetsProps) { const { t, locale } = useTranslation() - // The PDF is built outside React, so it cannot read this itself (#2066). - const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' - const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - // Fed the way the desktop dialog feeds it: the store's assignments and the switch, - // and the export applies the day plan's filter itself, so both shells print the same. - const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, planner.tripId) const open = shell.sheet?.id === 'export' const dayNotes = useTripStore(s => s.dayNotes) const [subscribeOpen, setSubscribeOpen] = useState(false) - const [pdfBusy, setPdfBusy] = useState(false) - const myId = useAuthStore(s => s.user?.id) - const offerMine = myId != null && hasPersonalPlan(planner.storedAssignments, planner.reservations) - const [icsBusy, setIcsBusy] = useState(false) - const [gpxBusy, setGpxBusy] = useState(false) + // Fed the way the desktop dialog feeds it: the store's assignments, and the export + // applies the day plan's filter itself, so both shells print the same. The PDF stays + // open here; a calendar or GPX file closes the sheet. + const { offerMine, isRunning, exportPdf, downloadIcs, downloadGpx } = useTripExport({ + tripId: planner.tripId, + data: { + trip: planner.trip, + days: planner.days, + places: planner.places, + assignments: planner.storedAssignments, + categories: planner.categories, + reservations: planner.reservations, + dayNotes, + }, + t, + locale, + toast: planner.toast, + exclusive: false, + onExported: shell.closeSheet, + requireTrip: true, + closeAfterPdf: false, + logPdfErrors: false, + }) // The subscription link reads the trip without an account, so it needs the // same permission as the public share link. The ICS download beside it does // not: that is a file this member may already read. const canManageShare = planner.can('share_manage', planner.trip) - const exportPdf = async (mine = false) => { - if (!planner.trip || pdfBusy) return - const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) => - notes.map(n => ({ ...n, day_id: Number(dayId) })), - ) - setPdfBusy(mine ? 'mine' : 'all') - try { - // See DayPlanSidebarToolbar: loaded on demand, not with the trip. - const { downloadTripPDF } = await importChunk(() => import('../../../../components/PDF/TripPDF')) - await downloadTripPDF({ - trip: planner.trip, - days: planner.days, - places: planner.places, - assignments: planner.storedAssignments, - categories: planner.categories, - dayNotes: flatNotes, - reservations: planner.reservations, - t, - locale, - timeFormat, - distanceUnit, - showServiceStops, - onlyUserId: mine ? myId : undefined, - }) - } catch (e) { - planner.toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`) - } finally { - setPdfBusy(false) - } - } - - const downloadIcs = async () => { - if (icsBusy) return - setIcsBusy(true) - try { - const res = await fetch(`/api/trips/${planner.tripId}/export.ics`, { credentials: 'include' }) - if (!res.ok) throw new Error() - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${planner.trip?.title || 'trip'}.ics` - document.body.appendChild(a) - a.click() - // Firefox/Safari cancel the download when the object URL is revoked - // before they picked the blob up. - setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100) - shell.closeSheet() - } catch { - planner.toast.error(t('planner.icsExportFailed')) - } finally { - setIcsBusy(false) - } - } - // Everything in one file here: the desktop menu's three scopes are a hover // affordance the phone does not have, and "the whole trip" is what you want // on a device anyway. - const downloadGpx = async () => { - if (gpxBusy) return - setGpxBusy(true) - try { - const res = await fetch(`/api/trips/${planner.tripId}/places/export.gpx`, { credentials: 'include' }) - if (res.status === 404) { planner.toast.info(t('dayplan.gpxEmpty')); return } - if (!res.ok) throw new Error() - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${planner.trip?.title || 'trip'}.gpx` - document.body.appendChild(a) - a.click() - setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100) - shell.closeSheet() - } catch { - planner.toast.error(t('dayplan.gpxFailed')) - } finally { - setGpxBusy(false) - } - } - return (
@@ -136,29 +66,29 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
void exportPdf()} /> {offerMine && ( void exportPdf(true)} /> )} void downloadIcs()} /> void downloadGpx()} + onClick={() => void downloadGpx('all', '')} /> {canManageShare && (