diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 8194f5f8e..666b28a7f 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -610,16 +610,17 @@ describe('DayPlanSidebar', () => { // ── PDF export ────────────────────────────────────────────────────────── - it('FE-PLANNER-DAYPLAN-025: PDF export button is present', () => { + it('FE-PLANNER-DAYPLAN-025: the export button is present', () => { render() - expect(screen.getByText('PDF')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument() }) - it('FE-PLANNER-DAYPLAN-026: clicking PDF button calls downloadTripPDF', async () => { + it('FE-PLANNER-DAYPLAN-026: the PDF row in the export dialog calls downloadTripPDF', async () => { const user = userEvent.setup() const { downloadTripPDF } = await import('../PDF/TripPDF') render() - await user.click(screen.getByText('PDF')) + await user.click(screen.getByRole('button', { name: 'Export' })) + await user.click(await screen.findByText('PDF')) await waitFor(() => { expect(downloadTripPDF).toHaveBeenCalled() }) @@ -900,11 +901,14 @@ describe('DayPlanSidebar', () => { // Day-title renaming moved to DayDetailPanel (#1065) — covered there. - // ── ICS export button ──────────────────────────────────────────────────── + // ── ICS export ─────────────────────────────────────────────────────────── - it('FE-PLANNER-DAYPLAN-043: ICS export button is present', () => { + it('FE-PLANNER-DAYPLAN-043: the calendar export sits in the export dialog', async () => { + const user = userEvent.setup() render() - expect(screen.getByText('ICS')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Export' })) + expect(await screen.findByText('Calendar')).toBeInTheDocument() + expect(screen.getByText('Download .ics')).toBeInTheDocument() }) // ── getMergedItems: transport merged with assignments ────────────────── @@ -1120,13 +1124,12 @@ describe('DayPlanSidebar', () => { // ── PDF hover tooltip ───────────────────────────────────────────────── - it('FE-PLANNER-DAYPLAN-054: hovering PDF button shows tooltip', async () => { + it('FE-PLANNER-DAYPLAN-054: hovering the export button shows its tooltip', async () => { const user = userEvent.setup() render() - const pdfBtn = screen.getByText('PDF').closest('button')! - await user.hover(pdfBtn) + await user.hover(screen.getByRole('button', { name: 'Export' })) await waitFor(() => { - // Tooltip text appears (from t('dayplan.pdfTooltip')) + // Tooltip text appears (from t('dayplan.exportIntro')) const tooltips = document.querySelectorAll('[style*="pointer-events: none"]') expect(tooltips.length).toBeGreaterThan(0) }) @@ -1194,10 +1197,9 @@ describe('DayPlanSidebar', () => { const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock') const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) render() - // The ICS button now opens a hover menu (Download / Subscribe) instead of - // downloading on direct click. - await user.hover(screen.getByText('ICS').closest('button')!) - await user.click(await screen.findByText('Download ICS')) + // The three export buttons collapsed into one that opens the export dialog. + await user.click(screen.getByRole('button', { name: 'Export' })) + await user.click(await screen.findByText('Download .ics')) await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object))) fetchSpy.mockRestore() createObjURL.mockRestore() @@ -1831,8 +1833,8 @@ describe('DayPlanSidebar', () => { render() - const pdfBtn = screen.getByRole('button', { name: /pdf/i }) - await user.click(pdfBtn) + await user.click(screen.getByRole('button', { name: 'Export' })) + await user.click(await screen.findByText('PDF')) await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledWith( expect.objectContaining({ dayNotes: expect.arrayContaining([expect.objectContaining({ text: 'PDF Note' })]) }) )) @@ -1916,16 +1918,17 @@ describe('DayPlanSidebar', () => { } }) - // ── ICS hover tooltip ───────────────────────────────────────────────────── + // ── Export dialog ───────────────────────────────────────────────────────── - it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows the download/subscribe menu', async () => { + it('FE-PLANNER-DAYPLAN-090: the export button opens the dialog with every format', async () => { const user = userEvent.setup() render() - const icsBtn = screen.getByText('ICS').closest('button')! - await user.hover(icsBtn) + await user.click(screen.getByRole('button', { name: 'Export' })) await waitFor(() => { - expect(screen.getByText('Download ICS')).toBeInTheDocument() + expect(screen.getByText('PDF')).toBeInTheDocument() + expect(screen.getByText('Download .ics')).toBeInTheDocument() expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument() + expect(screen.getByText('Whole trip')).toBeInTheDocument() }) }) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 1d618d136..28b68ee31 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -211,7 +211,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const [lockedIds, setLockedIds] = useState(new Set()) const [lockHoverId, setLockHoverId] = useState(null) const [undoHover, setUndoHover] = useState(false) - const [icsHover, setIcsHover] = useState(false) const [hoveredAssignmentId, setHoveredAssignmentId] = useState(null) // Transit rows fold their itinerary out inline (#1065). const [expandedTransitIds, setExpandedTransitIds] = useState>(new Set()) @@ -1034,8 +1033,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { setLockHoverId, undoHover, setUndoHover, - icsHover, - setIcsHover, hoveredAssignmentId, setHoveredAssignmentId, dropTargetKey, @@ -1209,8 +1206,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP setLockHoverId, undoHover, setUndoHover, - icsHover, - setIcsHover, hoveredAssignmentId, setHoveredAssignmentId, dropTargetKey, @@ -1405,8 +1400,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP t={t} locale={locale} toast={toast} - icsHover={icsHover} - setIcsHover={setIcsHover} expandedDays={expandedDays} setExpandedDays={setExpandedDays} onUndo={onUndo} diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx index b4a56f049..594102051 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx @@ -1,15 +1,15 @@ -// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-022 +// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023 import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' import { downloadTripPDF } from '../PDF/TripPDF' -import { buildDay, buildDayNote, buildReservation, buildTrip } from '../../../tests/helpers/factories' +import { buildDay, buildReservation, buildTrip } from '../../../tests/helpers/factories' import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar' import type { Reservation } from '../../types' vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) })) // The subscribe dialog fetches its feed token on mount; it is exercised in its -// own test, here we only care that opening the menu entry mounts it. +// own test, and TripExportModal covers the entry that opens it. vi.mock('./IcsSubscribeModal', () => ({ IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
@@ -46,8 +46,6 @@ function makeProps(overrides: Partial {}), expandedDays: new Set(), setExpandedDays: vi.fn((_v: Set) => {}), canUndo: false, @@ -72,37 +70,42 @@ beforeEach(() => { }) describe('DayPlanSidebarToolbar', () => { - // ── PDF export ──────────────────────────────────────────────────────────── + // ── Export ───────────────────────────────────────────────────── + // PDF, ICS and GPX each used to be their own button with its own hover menu. + // The mechanics now live in TripExportModal and are tested there; what is left + // here is the single button that opens it. - it('FE-PLANNER-DPTOOLBAR-001: the PDF button exports the trip with the day notes flattened', async () => { - const user = userEvent.setup() - const days = [buildDay({ id: 10, title: 'Day 1' })] - const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] } - render() - await user.click(screen.getByText('dayplan.pdf')) - await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1)) - expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({ - trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })], - }) + it('FE-PLANNER-DPTOOLBAR-001: the toolbar carries one export button, not three', () => { + render() + expect(screen.getByRole('button', { name: 'dayplan.export' })).toBeInTheDocument() + expect(screen.queryByText('ICS')).not.toBeInTheDocument() + expect(screen.queryByText('GPX')).not.toBeInTheDocument() + expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument() }) - it('FE-PLANNER-DPTOOLBAR-002: a failing PDF export surfaces the error via the toast', async () => { - const user = userEvent.setup() - vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing')) - const toast = makeToast() - render() - await user.click(screen.getByText('dayplan.pdf')) - await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing')) - }) - - it('FE-PLANNER-DPTOOLBAR-003: hovering the PDF button shows the export tooltip', async () => { + it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => { const user = userEvent.setup() render() - expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument() - await user.hover(screen.getByText('dayplan.pdf')) - await waitFor(() => expect(screen.getByText('dayplan.pdfTooltip')).toBeInTheDocument()) - await user.unhover(screen.getByText('dayplan.pdf')) - await waitFor(() => expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument()) + const btn = screen.getByRole('button', { name: 'dayplan.export' }) + expect(btn).toHaveAttribute('aria-expanded', 'false') + await user.click(btn) + expect(btn).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument() + expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument() + await user.click(screen.getByText('dayplan.pdf')) + await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(screen.queryByText('dayplan.exportDocument')).not.toBeInTheDocument()) + }) + + it('FE-PLANNER-DPTOOLBAR-003: hovering the export button shows its tooltip', async () => { + const user = userEvent.setup() + render() + expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument() + const btn = screen.getByRole('button', { name: 'dayplan.export' }) + await user.hover(btn) + await waitFor(() => expect(screen.getByText('dayplan.exportIntro')).toBeInTheDocument()) + await user.unhover(btn) + await waitFor(() => expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument()) }) // The button sits at the right edge of the leftmost pane. Its own tooltip was @@ -111,8 +114,8 @@ describe('DayPlanSidebarToolbar', () => { it('FE-PLANNER-DPTOOLBAR-004: the export tooltip stays inside the window', async () => { const user = userEvent.setup() render() - await user.hover(screen.getByText('dayplan.pdf')) - const tip = await screen.findByText('dayplan.pdfTooltip') + await user.hover(screen.getByRole('button', { name: 'dayplan.export' })) + const tip = await screen.findByText('dayplan.exportIntro') expect(tip.closest('[role="tooltip"]')).not.toBeNull() expect(tip.parentElement).toBe(document.body) expect(tip.style.position).toBe('fixed') @@ -120,94 +123,12 @@ describe('DayPlanSidebarToolbar', () => { await waitFor(() => expect(parseFloat(tip.style.left)).toBeGreaterThanOrEqual(0)) }) - // ── ICS menu ────────────────────────────────────────────────────────────── - - it('FE-PLANNER-DPTOOLBAR-005: hovering ICS opens the download/subscribe menu', async () => { - const user = userEvent.setup() - const setIcsHover = vi.fn((_v: boolean) => {}) - render() - expect(screen.queryByText('Download ICS')).not.toBeInTheDocument() - await user.hover(screen.getByText('ICS')) - expect(screen.getByText('Download ICS')).toBeInTheDocument() - expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument() - expect(setIcsHover).toHaveBeenCalledWith(true) - }) - - it('FE-PLANNER-DPTOOLBAR-005b: without share_manage the menu offers the download but not the subscription', async () => { - // The subscription mints a link that reads the trip without an account, so - // it needs share_manage; the one-off download is a file this member may - // already read. Leaving the entry visible would only produce a dialog whose - // enable button the server refuses. + it('FE-PLANNER-DPTOOLBAR-005: the dialog inherits the share permission from the toolbar', async () => { const user = userEvent.setup() render() - await user.hover(screen.getByText('ICS')) - expect(screen.getByText('Download ICS')).toBeInTheDocument() - expect(screen.queryByText('Subscribe to calendar')).not.toBeInTheDocument() - }) - - it('FE-PLANNER-DPTOOLBAR-006: leaving the ICS area closes the menu after the grace period', async () => { - const user = userEvent.setup() - const setIcsHover = vi.fn((_v: boolean) => {}) - render() - const icsButton = screen.getByText('ICS') - await user.hover(icsButton) - expect(screen.getByText('Download ICS')).toBeInTheDocument() - await user.unhover(icsButton) - await waitFor(() => expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()) - expect(setIcsHover).toHaveBeenLastCalledWith(false) - }) - - it('FE-PLANNER-DPTOOLBAR-007: re-entering the ICS area cancels the pending close', async () => { - const user = userEvent.setup() - render() - const icsButton = screen.getByText('ICS') - await user.hover(icsButton) - await user.unhover(icsButton) - await user.hover(icsButton) - // The close is scheduled 120ms out; coming back in has to cancel it. - await new Promise(r => setTimeout(r, 200)) - expect(screen.getByText('Download ICS')).toBeInTheDocument() - }) - - it('FE-PLANNER-DPTOOLBAR-008: "Download ICS" fetches the export and hands it to a download link', async () => { - const user = userEvent.setup() - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: true, - blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })), - } as unknown as Response) - const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock') - const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) - const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) - render() - await user.hover(screen.getByText('ICS')) - await user.click(screen.getByText('Download ICS')) - await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' })) - await waitFor(() => expect(clickSpy).toHaveBeenCalled()) - expect(revokeObjURL).toHaveBeenCalledWith('blob:mock') - // The menu closes itself once the download starts. - expect(screen.queryByText('Download ICS')).not.toBeInTheDocument() - fetchSpy.mockRestore(); createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore() - }) - - it('FE-PLANNER-DPTOOLBAR-009: a rejected ICS export shows the failure toast', async () => { - const user = userEvent.setup() - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false } as unknown as Response) - const toast = makeToast() - render() - await user.hover(screen.getByText('ICS')) - await user.click(screen.getByText('Download ICS')) - await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed')) - fetchSpy.mockRestore() - }) - - it('FE-PLANNER-DPTOOLBAR-010: "Subscribe to calendar" opens the subscribe dialog and closes again', async () => { - const user = userEvent.setup() - render() - await user.hover(screen.getByText('ICS')) - await user.click(screen.getByText('Subscribe to calendar')) - expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument() - await user.click(screen.getByText('close-subscribe')) - expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'dayplan.export' })) + expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument() + expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument() }) // ── Expand / collapse all ──────────────────────────────────────────────── @@ -350,105 +271,6 @@ describe('DayPlanSidebarToolbar', () => { expect(routeBtn.style.background).toBe('var(--bg-hover)') await user.unhover(routeBtn) expect(routeBtn.style.background).toBe('transparent') - - await user.hover(screen.getByText('ICS')) - const download = screen.getByText('Download ICS') - const subscribe = screen.getByText('Subscribe to calendar') - await user.hover(download) - expect(download.style.background).toContain('var(--bg-hover') - // Sliding down to the next entry has to clear the one we left. - await user.hover(subscribe) - expect(download.style.background).toBe('transparent') - expect(subscribe.style.background).toContain('var(--bg-hover') - await user.unhover(subscribe) - expect(subscribe.style.background).toBe('transparent') }) - it('FE-PLANNER-DPTOOLBAR-024: an aborted PDF export without an Error still reaches the toast', async () => { - const user = userEvent.setup() - vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom') - const toast = makeToast() - render() - await user.click(screen.getByText('dayplan.pdf')) - await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom')) - }) - - // ── GPX export (#1442) ──────────────────────────────────────────────────── - describe('GPX menu', () => { - let clickedHref: string | null - - beforeEach(() => { - clickedHref = null - // jsdom has neither of these, and an anchor click would navigate. - globalThis.URL.createObjectURL = vi.fn(() => 'blob:gpx') - globalThis.URL.revokeObjectURL = vi.fn() - vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { - clickedHref = this.href - }) - }) - - afterEach(() => vi.restoreAllMocks()) - - const okResponse = () => ({ ok: true, status: 200, blob: async () => new Blob(['']) }) as unknown as Response - - it('FE-PLANNER-DPTOOLBAR-025: the menu offers the three scopes on hover', async () => { - const user = userEvent.setup() - render() - expect(screen.queryByText('dayplan.gpxAll')).not.toBeInTheDocument() - await user.hover(screen.getByText('GPX')) - expect(screen.getByText('dayplan.gpxAll')).toBeInTheDocument() - expect(screen.getByText('dayplan.gpxPlaces')).toBeInTheDocument() - expect(screen.getByText('dayplan.gpxDays')).toBeInTheDocument() - }) - - it('FE-PLANNER-DPTOOLBAR-026: each scope asks the server for exactly its own selection', async () => { - const user = userEvent.setup() - const fetchMock = vi.fn(async () => okResponse()) - vi.stubGlobal('fetch', fetchMock) - render() - - for (const [label, expected] of [ - ['dayplan.gpxAll', '/api/trips/1/places/export.gpx'], - ['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'], - ['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'], - ] as const) { - await user.hover(screen.getByText('GPX')) - await user.click(screen.getByText(label)) - await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' })) - fetchMock.mockClear() - } - }) - - it('FE-PLANNER-DPTOOLBAR-027: a download names the file after the trip', async () => { - const user = userEvent.setup() - vi.stubGlobal('fetch', vi.fn(async () => okResponse())) - render() - await user.hover(screen.getByText('GPX')) - await user.click(screen.getByText('dayplan.gpxAll')) - await waitFor(() => expect(clickedHref).toBe('blob:gpx')) - expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled() - }) - - it('FE-PLANNER-DPTOOLBAR-028: an empty trip says so instead of reporting a failure', async () => { - const user = userEvent.setup() - const toast = makeToast() - vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response)) - render() - await user.hover(screen.getByText('GPX')) - await user.click(screen.getByText('dayplan.gpxAll')) - await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty')) - expect(toast.error).not.toHaveBeenCalled() - expect(clickedHref).toBeNull() - }) - - it('FE-PLANNER-DPTOOLBAR-029: a real failure toasts the error', async () => { - const user = userEvent.setup() - const toast = makeToast() - vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response)) - render() - await user.hover(screen.getByText('GPX')) - await user.click(screen.getByText('dayplan.gpxAll')) - await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed')) - }) - }) }) diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.tsx index d37cf3fc9..4e8b36c67 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.tsx @@ -1,24 +1,12 @@ -import React, { useState, useRef } from 'react' -import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus, MapPin, Route as RouteIcon } from 'lucide-react' +import { useState } from 'react' +import { ChevronsDownUp, ChevronsUpDown, Download, Undo2, ArrowUpDown, Route as RouteIcon } from 'lucide-react' import { DayReorderPopup } from './DayReorderPopup' import Tooltip from '../shared/Tooltip' import { useToast } from '../shared/Toast' -import { IcsSubscribeModal } from './IcsSubscribeModal' +import { TripExportModal } from './TripExportModal' import { isRoutableReservation } from '../../utils/reservationRoutes' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' -/** - * What a GPX download can carry. Worded by what someone wants on their device - * rather than by the GPX element it maps to: "everything", "just the places" for - * an offline map, "just the days" for following a plan. An omitted flag defaults - * to true server-side, so the first entry needs no query at all. - */ -const GPX_SCOPES = [ - { key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: FileDown }, - { key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin }, - { key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon }, -] as const - interface DayPlanSidebarToolbarProps { tripId: number trip: Trip @@ -33,8 +21,6 @@ interface DayPlanSidebarToolbarProps { t: (key: string, params?: Record) => string locale: string toast: ReturnType - icsHover: boolean - setIcsHover: (v: boolean) => void expandedDays: Set setExpandedDays: (next: Set) => void onUndo?: () => void @@ -44,11 +30,9 @@ interface DayPlanSidebarToolbarProps { lastActionLabel: string | null canEditDays?: boolean /** - * Gates "Subscribe to calendar" only. The one-off "Download ICS" below it - * stays open to every member: it is a file they already have the right to - * read, while the subscription mints a link that works without an account. - * Defaults to true so a caller that has not wired the permission through - * keeps today's menu rather than silently losing an entry. + * Gates "Subscribe to calendar" in the export dialog only. Defaults to true so + * a caller that has not wired the permission through keeps today's entries + * rather than silently losing one. */ canManageShare?: boolean onReorderDays?: (orderedIds: number[]) => void @@ -58,230 +42,53 @@ interface DayPlanSidebarToolbarProps { export function DayPlanSidebarToolbar({ tripId, trip, days, places, categories, assignments, reservations, dayNotes, allConnectionsShown = false, onToggleAllConnections, - t, locale, toast, setIcsHover, + t, locale, toast, expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel, canEditDays, canManageShare = true, onReorderDays, onAddDay, }: DayPlanSidebarToolbarProps) { const [reorderOpen, setReorderOpen] = useState(false) - const [subscribeOpen, setSubscribeOpen] = useState(false) - const icsMenuTimeoutRef = useRef | null>(null) - const [icsMenuVisible, setIcsMenuVisible] = useState(false) - const gpxMenuTimeoutRef = useRef | null>(null) - const [gpxMenuVisible, setGpxMenuVisible] = useState(false) + const [exportOpen, setExportOpen] = useState(false) - const showIcsMenu = () => { - if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current) - setIcsMenuVisible(true) - setIcsHover(true) - } - const hideIcsMenu = () => { - icsMenuTimeoutRef.current = setTimeout(() => { - setIcsMenuVisible(false) - setIcsHover(false) - }, 120) - } - - const showGpxMenu = () => { - if (gpxMenuTimeoutRef.current) clearTimeout(gpxMenuTimeoutRef.current) - setGpxMenuVisible(true) - } - const hideGpxMenu = () => { - gpxMenuTimeoutRef.current = setTimeout(() => setGpxMenuVisible(false), 120) - } - - const downloadGpx = async (query: string) => { - setGpxMenuVisible(false) - 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() - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${trip?.title || 'trip'}.gpx` - a.click() - URL.revokeObjectURL(url) - } catch { toast.error(t('dayplan.gpxFailed')) } - } - - const menuItemStyle: React.CSSProperties = { - display: 'flex', alignItems: 'center', gap: 7, - width: '100%', padding: '7px 12px', border: 'none', - background: 'transparent', cursor: 'pointer', - fontSize: 11, fontWeight: 500, fontFamily: 'inherit', - color: 'var(--text-primary)', textAlign: 'left', - transition: 'background 0.1s', - } return (
-
- + {/* One export button instead of three: PDF, ICS and GPX each carried + their own hover menu, and on a narrower sidebar the row ran out of + width and pushed them off the edge. The dialog holds every option. */} + - -
-
- - {icsMenuVisible && ( -
- - {canManageShare && ( - - )} -
- )} -
- {/* GPX — the counterpart to the GPX import in the places sidebar, sitting - with the other exports rather than with the importer. Same hover-menu - shape as ICS beside it. */} -
- - {gpxMenuVisible && ( -
- {GPX_SCOPES.map(scope => ( - - ))} -
- )} -
- {subscribeOpen && canManageShare && ( - setSubscribeOpen(false)} - /> - )} + + setExportOpen(false)} + tripId={tripId} + trip={trip} + days={days} + places={places} + categories={categories} + assignments={assignments} + reservations={reservations} + dayNotes={dayNotes} + t={t} + locale={locale} + toast={toast} + canManageShare={canManageShare} + /> {(() => { const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id)) const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll') diff --git a/client/src/components/Planner/TripExportModal.test.tsx b/client/src/components/Planner/TripExportModal.test.tsx new file mode 100644 index 000000000..7681079e2 --- /dev/null +++ b/client/src/components/Planner/TripExportModal.test.tsx @@ -0,0 +1,222 @@ +// FE-PLANNER-EXPORTMODAL-001 to FE-PLANNER-EXPORTMODAL-014 +import { render, screen, waitFor } from '../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { downloadTripPDF } from '../PDF/TripPDF' +import { buildDay, buildDayNote, buildTrip } from '../../../tests/helpers/factories' +import { TripExportModal } from './TripExportModal' + +vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) })) + +// The subscribe dialog fetches its feed token on mount; it is exercised in its +// own test, here we only care that the entry mounts it. +vi.mock('./IcsSubscribeModal', () => ({ + IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => ( +
+ {title} + +
+ ), +})) + +const t = (key: string, params?: Record) => + params ? `${key}|${Object.values(params).join('|')}` : key + +const trip = buildTrip({ id: 1, title: 'Roadtrip' }) + +function makeToast() { + return { + success: vi.fn((_m: string) => {}), + error: vi.fn((_m: string) => {}), + warning: vi.fn((_m: string) => {}), + info: vi.fn((_m: string) => {}), + } +} + +function makeProps(overrides: Partial> = {}) { + return { + isOpen: true, + onClose: vi.fn(), + tripId: 1, + trip, + days: [], + places: [], + categories: [], + assignments: {}, + reservations: [], + dayNotes: {}, + t, + locale: 'en-US', + toast: makeToast(), + ...overrides, + } as React.ComponentProps +} + +let clickedHref: string | null + +beforeEach(() => { + vi.clearAllMocks() + clickedHref = null + // jsdom has neither of these, and an anchor click would navigate. + globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock') + globalThis.URL.revokeObjectURL = vi.fn() + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + clickedHref = this.href + }) +}) + +afterEach(() => vi.restoreAllMocks()) + +const okResponse = (body = '') => + ({ ok: true, status: 200, blob: async () => new Blob([body]) }) as unknown as Response + +describe('TripExportModal', () => { + it('FE-PLANNER-EXPORTMODAL-001: closed, it renders nothing', () => { + render() + expect(screen.queryByText('dayplan.export')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-EXPORTMODAL-002: open, every export sits in its own section', () => { + render() + expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument() + expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument() + expect(screen.getByText('dayplan.exportMaps · GPX')).toBeInTheDocument() + for (const label of ['dayplan.pdf', 'mobileTrip.icsDownload', 'mobileTrip.icsSubscribe', + 'dayplan.gpxAll', 'dayplan.gpxPlaces', 'dayplan.gpxDays']) { + expect(screen.getByText(label)).toBeInTheDocument() + } + }) + + // ── PDF ─────────────────────────────────────────────────────────────────── + + it('FE-PLANNER-EXPORTMODAL-003: the PDF row exports the trip with the day notes flattened', async () => { + const user = userEvent.setup() + const days = [buildDay({ id: 10, title: 'Day 1' })] + const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] } + render() + await user.click(screen.getByText('dayplan.pdf')) + await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1)) + expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({ + trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })], + }) + }) + + it('FE-PLANNER-EXPORTMODAL-004: a finished PDF export closes the dialog', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render() + await user.click(screen.getByText('dayplan.pdf')) + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + }) + + it('FE-PLANNER-EXPORTMODAL-005: a failing PDF export toasts the error and keeps the dialog open', async () => { + const user = userEvent.setup() + vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing')) + const toast = makeToast() + const onClose = vi.fn() + render() + await user.click(screen.getByText('dayplan.pdf')) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing')) + expect(onClose).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-EXPORTMODAL-006: an aborted PDF export without an Error still reaches the toast', async () => { + const user = userEvent.setup() + vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom') + const toast = makeToast() + render() + await user.click(screen.getByText('dayplan.pdf')) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom')) + }) + + // ── Calendar ────────────────────────────────────────────────────────────── + + it('FE-PLANNER-EXPORTMODAL-007: the ICS row fetches the export and hands it to a download link', async () => { + const user = userEvent.setup() + const fetchMock = vi.fn(async () => okResponse('BEGIN:VCALENDAR')) + vi.stubGlobal('fetch', fetchMock) + render() + await user.click(screen.getByText('mobileTrip.icsDownload')) + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' })) + await waitFor(() => expect(clickedHref).toBe('blob:mock')) + }) + + it('FE-PLANNER-EXPORTMODAL-008: a rejected ICS export shows the failure toast', async () => { + const user = userEvent.setup() + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response)) + const toast = makeToast() + render() + await user.click(screen.getByText('mobileTrip.icsDownload')) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed')) + expect(clickedHref).toBeNull() + }) + + it('FE-PLANNER-EXPORTMODAL-009: the subscribe row opens the subscribe dialog and closes again', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByText('mobileTrip.icsSubscribe')) + expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument() + await user.click(screen.getByText('close-subscribe')) + expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument() + }) + + // The subscription mints a link that reads the trip without an account, so it + // needs share_manage; the one-off download is a file this member may already + // read. Leaving the entry visible would only produce a dialog whose enable + // button the server refuses. + it('FE-PLANNER-EXPORTMODAL-010: without share_manage the download stays and the subscription goes', () => { + render() + expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument() + expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument() + }) + + // ── GPX (#1442) ─────────────────────────────────────────────────────────── + + it('FE-PLANNER-EXPORTMODAL-011: each scope asks the server for exactly its own selection', async () => { + const user = userEvent.setup() + const fetchMock = vi.fn(async () => okResponse()) + vi.stubGlobal('fetch', fetchMock) + for (const [label, expected] of [ + ['dayplan.gpxAll', '/api/trips/1/places/export.gpx'], + ['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'], + ['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'], + ] as const) { + const view = render() + await user.click(screen.getByText(label)) + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' })) + fetchMock.mockClear() + view.unmount() + } + }) + + it('FE-PLANNER-EXPORTMODAL-012: a download names the file after the trip', async () => { + const user = userEvent.setup() + vi.stubGlobal('fetch', vi.fn(async () => okResponse())) + render() + await user.click(screen.getByText('dayplan.gpxAll')) + await waitFor(() => expect(clickedHref).toBe('blob:mock')) + await waitFor(() => expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled()) + }) + + it('FE-PLANNER-EXPORTMODAL-013: an empty trip says so instead of reporting a failure', async () => { + const user = userEvent.setup() + const toast = makeToast() + const onClose = vi.fn() + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response)) + render() + await user.click(screen.getByText('dayplan.gpxAll')) + await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty')) + expect(toast.error).not.toHaveBeenCalled() + expect(clickedHref).toBeNull() + // Nothing was downloaded, so the dialog stays put. + expect(onClose).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-EXPORTMODAL-014: a real failure toasts the error', async () => { + const user = userEvent.setup() + const toast = makeToast() + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response)) + render() + await user.click(screen.getByText('dayplan.gpxAll')) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed')) + }) +}) diff --git a/client/src/components/Planner/TripExportModal.tsx b/client/src/components/Planner/TripExportModal.tsx new file mode 100644 index 000000000..365032472 --- /dev/null +++ b/client/src/components/Planner/TripExportModal.tsx @@ -0,0 +1,246 @@ +import { useState, type ReactNode } from 'react' +import { CalendarDays, CalendarPlus, ChevronRight, Download, FileText, Loader2, MapPin, Route as RouteIcon } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import Modal from '../shared/Modal' +import { IcsSubscribeModal } from './IcsSubscribeModal' +import { useToast } from '../shared/Toast' +import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' + +/** + * What a GPX download can carry. Worded by what someone wants on their device + * rather than by the GPX element it maps to: "everything", "just the places" for + * an offline map, "just the days" for following a plan. An omitted flag defaults + * to true server-side, so the first entry needs no query at all. + */ +const GPX_SCOPES = [ + { key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: Download }, + { key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin }, + { key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon }, +] as const + +interface TripExportModalProps { + isOpen: boolean + onClose: () => void + tripId: number + trip: Trip + days: Day[] + places: Place[] + categories: Category[] + assignments: AssignmentsMap + reservations: Reservation[] + dayNotes: Record + t: (key: string, params?: Record) => string + locale: string + toast: ReturnType + /** + * Gates "Subscribe to calendar" only. The one-off ICS download above it stays + * open to every member: it is a file they already have the right to read, + * while the subscription mints a link that works without an account. + */ + canManageShare?: boolean +} + +/** + * Every way a trip leaves TREK, in one dialog: the day plan as a PDF, the + * bookings as a calendar (a one-off .ics or a live subscription) and the map + * data as GPX in the three scopes a device actually wants. + * + * It replaces three separate toolbar buttons, two of which opened hover menus of + * their own. On a narrow sidebar that row simply ran out of width and the + * exports dropped off the edge; one button cannot. + */ +export function TripExportModal({ + isOpen, onClose, tripId, trip, days, places, categories, assignments, reservations, dayNotes, + 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) + 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 exportPdf = async () => { + if (busy) return + setBusy('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 import('../PDF/TripPDF') + await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale }) + 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) + } + } + + return ( + <> + + + + + {t('dayplan.export')} + + } + > +

{t('dayplan.exportIntro')}

+ +
+ +
+ +
+ + {canManageShare && ( + setSubscribeOpen(true)} + /> + )} +
+ + {/* GPX — the counterpart to the GPX import in the places sidebar. The + format name carries more than a translated heading would, so it is + spelled out beside the plain-language one. */} +
+ {GPX_SCOPES.map(scope => ( + downloadGpx(scope.key, scope.query)} + /> + ))} +
+
+ + {subscribeOpen && canManageShare && ( + setSubscribeOpen(false)} + /> + )} + + ) +} + +function Section({ label, children }: { label: string; children: ReactNode }) { + return ( +
+

{label}

+
+ {children} +
+
+ ) +} + +function ExportRow({ icon: Icon, title, sub, busy = false, disabled = false, onClick }: { + icon: LucideIcon + title: string + sub?: string + busy?: boolean + disabled?: boolean + onClick: () => void +}) { + return ( + + ) +} diff --git a/shared/src/i18n/ar/dayplan.ts b/shared/src/i18n/ar/dayplan.ts index 522ba5a26..80744ffbe 100644 --- a/shared/src/i18n/ar/dayplan.ts +++ b/shared/src/i18n/ar/dayplan.ts @@ -67,6 +67,11 @@ const dayplan: TranslationStrings = { 'dayplan.pdfError': 'فشل تصدير PDF', 'dayplan.expandAll': 'Expand all days', // en-fallback 'dayplan.collapseAll': 'Collapse all days', // en-fallback + 'dayplan.export': 'تصدير', + 'dayplan.exportIntro': 'خذ خطتك معك: كمستند أو في تقويمك أو على جهاز GPS.', + 'dayplan.exportDocument': 'مستند', + 'dayplan.exportCalendar': 'التقويم', + 'dayplan.exportMaps': 'الخرائط وGPS', 'dayplan.pdf': 'PDF', // en-fallback 'dayplan.mobile.addPlace': 'Add Place', // en-fallback 'dayplan.mobile.searchPlaces': 'Search places...', // en-fallback diff --git a/shared/src/i18n/br/dayplan.ts b/shared/src/i18n/br/dayplan.ts index a532ac796..dae5e458b 100644 --- a/shared/src/i18n/br/dayplan.ts +++ b/shared/src/i18n/br/dayplan.ts @@ -49,6 +49,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Nenhum lugar com coordenadas para calcular a rota', 'dayplan.confirmed': 'Confirmada', 'dayplan.pendingRes': 'Pendente', + 'dayplan.export': 'Exportar', + 'dayplan.exportIntro': 'Leve seu plano com você: como documento, no calendário ou no GPS.', + 'dayplan.exportDocument': 'Documento', + 'dayplan.exportCalendar': 'Calendário', + 'dayplan.exportMaps': 'Mapas e GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Exportar plano do dia em PDF', 'dayplan.gpxTooltip': 'Exportar como GPX para mapas offline e aparelhos de GPS', diff --git a/shared/src/i18n/ca/dayplan.ts b/shared/src/i18n/ca/dayplan.ts index 99ff78b63..a721ae600 100644 --- a/shared/src/i18n/ca/dayplan.ts +++ b/shared/src/i18n/ca/dayplan.ts @@ -49,6 +49,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': "No s'han trobat llocs amb coordenades per calcular la ruta", 'dayplan.confirmed': 'Confirmat', 'dayplan.pendingRes': 'Pendent', + 'dayplan.export': 'Exporta', + 'dayplan.exportIntro': "Emporta't el pla: com a document, al calendari o al GPS.", + 'dayplan.exportDocument': 'Document', + 'dayplan.exportCalendar': 'Calendari', + 'dayplan.exportMaps': 'Mapes i GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Exporta el pla diari com a PDF', 'dayplan.gpxTooltip': 'Exporta com a GPX per a mapes fora de línia i dispositius GPS', diff --git a/shared/src/i18n/cs/dayplan.ts b/shared/src/i18n/cs/dayplan.ts index aaf1e931f..e23b8ebda 100644 --- a/shared/src/i18n/cs/dayplan.ts +++ b/shared/src/i18n/cs/dayplan.ts @@ -49,6 +49,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Nebyla nalezena žádná místa se souřadnicemi pro výpočet trasy', 'dayplan.confirmed': 'Potvrzeno', 'dayplan.pendingRes': 'Čeká na potvrzení', + 'dayplan.export': 'Exportovat', + 'dayplan.exportIntro': 'Vezmi si plán s sebou: jako dokument, do kalendáře nebo do GPS.', + 'dayplan.exportDocument': 'Dokument', + 'dayplan.exportCalendar': 'Kalendář', + 'dayplan.exportMaps': 'Mapy a GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Exportovat denní plán do PDF', 'dayplan.gpxTooltip': 'Exportovat jako GPX pro offline mapy a GPS zařízení', diff --git a/shared/src/i18n/de/dayplan.ts b/shared/src/i18n/de/dayplan.ts index 14f461a5b..4b9858bbc 100644 --- a/shared/src/i18n/de/dayplan.ts +++ b/shared/src/i18n/de/dayplan.ts @@ -60,6 +60,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Keine Orte mit Koordinaten für Routenberechnung gefunden', 'dayplan.confirmed': 'Bestätigt', 'dayplan.pendingRes': 'Ausstehend', + 'dayplan.export': 'Exportieren', + 'dayplan.exportIntro': 'Nimm deinen Plan mit: als Dokument, im Kalender oder auf dem GPS.', + 'dayplan.exportDocument': 'Dokument', + 'dayplan.exportCalendar': 'Kalender', + 'dayplan.exportMaps': 'Karten & GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Tagesplan als PDF exportieren', 'dayplan.gpxTooltip': 'Als GPX für Offline-Karten und GPS-Geräte exportieren', diff --git a/shared/src/i18n/en/dayplan.ts b/shared/src/i18n/en/dayplan.ts index e2b1d5699..56dbc74ea 100644 --- a/shared/src/i18n/en/dayplan.ts +++ b/shared/src/i18n/en/dayplan.ts @@ -60,6 +60,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'No places with coordinates found for route calculation', 'dayplan.confirmed': 'Confirmed', 'dayplan.pendingRes': 'Pending', + 'dayplan.export': 'Export', + 'dayplan.exportIntro': 'Take your plan with you: as a document, in your calendar or on your GPS.', + 'dayplan.exportDocument': 'Document', + 'dayplan.exportCalendar': 'Calendar', + 'dayplan.exportMaps': 'Maps & GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Export day plan as PDF', 'dayplan.gpxTooltip': 'Export as GPX for offline maps and GPS devices', diff --git a/shared/src/i18n/es/dayplan.ts b/shared/src/i18n/es/dayplan.ts index 0e652d7a9..bbd75eaa2 100644 --- a/shared/src/i18n/es/dayplan.ts +++ b/shared/src/i18n/es/dayplan.ts @@ -49,6 +49,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'No se encontraron lugares con coordenadas para calcular la ruta', 'dayplan.confirmed': 'Confirmado', 'dayplan.pendingRes': 'Pendiente', + 'dayplan.export': 'Exportar', + 'dayplan.exportIntro': 'Llévate tu plan: como documento, en el calendario o en el GPS.', + 'dayplan.exportDocument': 'Documento', + 'dayplan.exportCalendar': 'Calendario', + 'dayplan.exportMaps': 'Mapas y GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Exportar plan diario como PDF', 'dayplan.gpxTooltip': 'Exportar como GPX para mapas sin conexión y dispositivos GPS', diff --git a/shared/src/i18n/fr/dayplan.ts b/shared/src/i18n/fr/dayplan.ts index 30961ac4b..b6b7e047e 100644 --- a/shared/src/i18n/fr/dayplan.ts +++ b/shared/src/i18n/fr/dayplan.ts @@ -49,6 +49,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': "Aucun lieu avec des coordonnées trouvé pour le calcul d'itinéraire", 'dayplan.confirmed': 'Confirmé', 'dayplan.pendingRes': 'En attente', + 'dayplan.export': 'Exporter', + 'dayplan.exportIntro': 'Emportez votre plan : en document, dans votre agenda ou sur votre GPS.', + 'dayplan.exportDocument': 'Document', + 'dayplan.exportCalendar': 'Agenda', + 'dayplan.exportMaps': 'Cartes et GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Exporter le plan du jour en PDF', 'dayplan.gpxTooltip': 'Exporter en GPX pour les cartes hors ligne et les GPS', diff --git a/shared/src/i18n/gr/dayplan.ts b/shared/src/i18n/gr/dayplan.ts index 0a985ff0f..4f70dc343 100644 --- a/shared/src/i18n/gr/dayplan.ts +++ b/shared/src/i18n/gr/dayplan.ts @@ -60,6 +60,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Δεν βρέθηκαν μέρη με συντεταγμένες για τον υπολογισμό διαδρομής', 'dayplan.confirmed': 'Επιβεβαιωμένο', 'dayplan.pendingRes': 'Εκκρεμές', + 'dayplan.export': 'Εξαγωγή', + 'dayplan.exportIntro': 'Πάρε το πλάνο σου μαζί: ως έγγραφο, στο ημερολόγιο ή στο GPS.', + 'dayplan.exportDocument': 'Έγγραφο', + 'dayplan.exportCalendar': 'Ημερολόγιο', + 'dayplan.exportMaps': 'Χάρτες και GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Εξαγωγή ημερήσιου πλάνου ως PDF', 'dayplan.gpxTooltip': 'Εξαγωγή ως GPX για χάρτες εκτός σύνδεσης και συσκευές GPS', diff --git a/shared/src/i18n/hu/dayplan.ts b/shared/src/i18n/hu/dayplan.ts index a3996e340..093c2b50e 100644 --- a/shared/src/i18n/hu/dayplan.ts +++ b/shared/src/i18n/hu/dayplan.ts @@ -49,6 +49,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Nem találhatók koordinátákkal rendelkező helyek az útvonalszámításhoz', 'dayplan.confirmed': 'Megerősítve', 'dayplan.pendingRes': 'Függőben', + 'dayplan.export': 'Exportálás', + 'dayplan.exportIntro': 'Vidd magaddal a tervet: dokumentumként, a naptáradban vagy a GPS-eden.', + 'dayplan.exportDocument': 'Dokumentum', + 'dayplan.exportCalendar': 'Naptár', + 'dayplan.exportMaps': 'Térképek és GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Napi terv exportálása PDF-be', 'dayplan.gpxTooltip': 'Exportálás GPX-ként offline térképekhez és GPS-eszközökhöz', diff --git a/shared/src/i18n/id/dayplan.ts b/shared/src/i18n/id/dayplan.ts index ad6b3555e..30e10ed1f 100644 --- a/shared/src/i18n/id/dayplan.ts +++ b/shared/src/i18n/id/dayplan.ts @@ -58,6 +58,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Tidak ditemukan tempat dengan koordinat untuk kalkulasi rute', 'dayplan.confirmed': 'Dikonfirmasi', 'dayplan.pendingRes': 'Menunggu', + 'dayplan.export': 'Ekspor', + 'dayplan.exportIntro': 'Bawa rencanamu: sebagai dokumen, di kalender, atau di GPS.', + 'dayplan.exportDocument': 'Dokumen', + 'dayplan.exportCalendar': 'Kalender', + 'dayplan.exportMaps': 'Peta & GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Ekspor rencana hari sebagai PDF', 'dayplan.gpxTooltip': 'Ekspor sebagai GPX untuk peta offline dan perangkat GPS', diff --git a/shared/src/i18n/it/dayplan.ts b/shared/src/i18n/it/dayplan.ts index dbde95b38..2973dfbfd 100644 --- a/shared/src/i18n/it/dayplan.ts +++ b/shared/src/i18n/it/dayplan.ts @@ -49,6 +49,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Nessun luogo con coordinate trovato per il calcolo del percorso', 'dayplan.confirmed': 'Confermata', 'dayplan.pendingRes': 'In attesa', + 'dayplan.export': 'Esporta', + 'dayplan.exportIntro': 'Porta con te il tuo piano: come documento, nel calendario o sul GPS.', + 'dayplan.exportDocument': 'Documento', + 'dayplan.exportCalendar': 'Calendario', + 'dayplan.exportMaps': 'Mappe e GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Esporta il programma del giorno come PDF', 'dayplan.gpxTooltip': 'Esporta in GPX per mappe offline e dispositivi GPS', diff --git a/shared/src/i18n/ja/dayplan.ts b/shared/src/i18n/ja/dayplan.ts index c1eea7d2f..221fe3245 100644 --- a/shared/src/i18n/ja/dayplan.ts +++ b/shared/src/i18n/ja/dayplan.ts @@ -60,6 +60,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': '座標付きの場所がありません', 'dayplan.confirmed': '確定', 'dayplan.pendingRes': '保留', + 'dayplan.export': 'エクスポート', + 'dayplan.exportIntro': '旅程を持ち歩けます。書類として、カレンダーに、GPS 機器に。', + 'dayplan.exportDocument': '書類', + 'dayplan.exportCalendar': 'カレンダー', + 'dayplan.exportMaps': '地図と GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': '日別計画をPDFで書き出し', 'dayplan.gpxTooltip': 'オフライン地図やGPS機器向けにGPXで書き出す', diff --git a/shared/src/i18n/ko/dayplan.ts b/shared/src/i18n/ko/dayplan.ts index 598228e0e..d636a211c 100644 --- a/shared/src/i18n/ko/dayplan.ts +++ b/shared/src/i18n/ko/dayplan.ts @@ -60,6 +60,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': '경로 계산을 위한 좌표가 있는 장소가 없습니다', 'dayplan.confirmed': '확정됨', 'dayplan.pendingRes': '대기 중', + 'dayplan.export': '내보내기', + 'dayplan.exportIntro': '일정을 가지고 다니세요. 문서로, 캘린더에, GPS 기기에.', + 'dayplan.exportDocument': '문서', + 'dayplan.exportCalendar': '캘린더', + 'dayplan.exportMaps': '지도 및 GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': '일별 계획을 PDF로 내보내기', 'dayplan.gpxTooltip': '오프라인 지도와 GPS 기기용 GPX로 내보내기', diff --git a/shared/src/i18n/nl/dayplan.ts b/shared/src/i18n/nl/dayplan.ts index 564928bd5..c10492a8c 100644 --- a/shared/src/i18n/nl/dayplan.ts +++ b/shared/src/i18n/nl/dayplan.ts @@ -49,6 +49,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Geen plaatsen met coördinaten gevonden voor routeberekening', 'dayplan.confirmed': 'Bevestigd', 'dayplan.pendingRes': 'In behandeling', + 'dayplan.export': 'Exporteren', + 'dayplan.exportIntro': 'Neem je plan mee: als document, in je agenda of op je gps.', + 'dayplan.exportDocument': 'Document', + 'dayplan.exportCalendar': 'Agenda', + 'dayplan.exportMaps': 'Kaarten en gps', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Dagplan exporteren als PDF', 'dayplan.gpxTooltip': 'Exporteren als GPX voor offline kaarten en gps-apparaten', diff --git a/shared/src/i18n/pl/dayplan.ts b/shared/src/i18n/pl/dayplan.ts index cfb968522..e2ce84ced 100644 --- a/shared/src/i18n/pl/dayplan.ts +++ b/shared/src/i18n/pl/dayplan.ts @@ -59,6 +59,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Nie znaleziono miejsc ze współrzędnymi do obliczenia trasy', 'dayplan.confirmed': 'Potwierdzono', 'dayplan.pendingRes': 'Oczekujące', + 'dayplan.export': 'Eksportuj', + 'dayplan.exportIntro': 'Zabierz plan ze sobą: jako dokument, do kalendarza albo na GPS.', + 'dayplan.exportDocument': 'Dokument', + 'dayplan.exportCalendar': 'Kalendarz', + 'dayplan.exportMaps': 'Mapy i GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Eksportuj plan dnia jako PDF', 'dayplan.gpxTooltip': 'Eksportuj jako GPX do map offline i urządzeń GPS', diff --git a/shared/src/i18n/ru/dayplan.ts b/shared/src/i18n/ru/dayplan.ts index c59ad2ef3..314deedb5 100644 --- a/shared/src/i18n/ru/dayplan.ts +++ b/shared/src/i18n/ru/dayplan.ts @@ -49,6 +49,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Не найдено мест с координатами для расчёта маршрута', 'dayplan.confirmed': 'Подтверждено', 'dayplan.pendingRes': 'Ожидание', + 'dayplan.export': 'Экспорт', + 'dayplan.exportIntro': 'Возьмите план с собой: документом, в календарь или на GPS.', + 'dayplan.exportDocument': 'Документ', + 'dayplan.exportCalendar': 'Календарь', + 'dayplan.exportMaps': 'Карты и GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Экспортировать план дня в PDF', 'dayplan.gpxTooltip': 'Экспорт в GPX для офлайн-карт и GPS-устройств', diff --git a/shared/src/i18n/sv/dayplan.ts b/shared/src/i18n/sv/dayplan.ts index 5842c5aa4..20719fea2 100644 --- a/shared/src/i18n/sv/dayplan.ts +++ b/shared/src/i18n/sv/dayplan.ts @@ -61,6 +61,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Inga platser med koordinater hittades för ruttberäkningen', 'dayplan.confirmed': 'Bekräftat', 'dayplan.pendingRes': 'Pendlande', + 'dayplan.export': 'Exportera', + 'dayplan.exportIntro': 'Ta med dig planen: som dokument, i kalendern eller på GPS:en.', + 'dayplan.exportDocument': 'Dokument', + 'dayplan.exportCalendar': 'Kalender', + 'dayplan.exportMaps': 'Kartor och GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Exportera dagsplanen som PDF', 'dayplan.gpxTooltip': 'Exportera som GPX för offlinekartor och GPS-enheter', diff --git a/shared/src/i18n/tr/dayplan.ts b/shared/src/i18n/tr/dayplan.ts index 604b462a8..6facc7ca4 100644 --- a/shared/src/i18n/tr/dayplan.ts +++ b/shared/src/i18n/tr/dayplan.ts @@ -60,6 +60,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Rota için koordinatlı yer bulunamadı', 'dayplan.confirmed': 'Onaylandı', 'dayplan.pendingRes': 'Beklemede', + 'dayplan.export': 'Dışa aktar', + 'dayplan.exportIntro': 'Planını yanında taşı: belge olarak, takviminde ya da GPS cihazında.', + 'dayplan.exportDocument': 'Belge', + 'dayplan.exportCalendar': 'Takvim', + 'dayplan.exportMaps': 'Haritalar ve GPS', 'dayplan.pdf': "PDF'ler", 'dayplan.pdfTooltip': 'Gün planını PDF olarak dışa aktar', 'dayplan.gpxTooltip': 'Çevrimdışı haritalar ve GPS cihazları için GPX olarak dışa aktar', diff --git a/shared/src/i18n/uk/dayplan.ts b/shared/src/i18n/uk/dayplan.ts index f6af31419..f45358dbd 100644 --- a/shared/src/i18n/uk/dayplan.ts +++ b/shared/src/i18n/uk/dayplan.ts @@ -51,6 +51,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Не знайдено місць з координатами для розрахунку маршруту', 'dayplan.confirmed': 'Подтверждено', 'dayplan.pendingRes': 'Ожидание', + 'dayplan.export': 'Експорт', + 'dayplan.exportIntro': 'Візьміть план із собою: документом, у календар або на GPS.', + 'dayplan.exportDocument': 'Документ', + 'dayplan.exportCalendar': 'Календар', + 'dayplan.exportMaps': 'Карти та GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Експортувати план дня в PDF', 'dayplan.gpxTooltip': 'Експорт у GPX для офлайн-карт і GPS-пристроїв', diff --git a/shared/src/i18n/vi/dayplan.ts b/shared/src/i18n/vi/dayplan.ts index e0cc97c8f..61a0d5d34 100644 --- a/shared/src/i18n/vi/dayplan.ts +++ b/shared/src/i18n/vi/dayplan.ts @@ -60,6 +60,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': 'Không tìm thấy địa điểm nào có tọa độ để tính toán tuyến đường', 'dayplan.confirmed': 'Đã xác nhận', 'dayplan.pendingRes': 'Chưa giải quyết', + 'dayplan.export': 'Xuất', + 'dayplan.exportIntro': 'Mang kế hoạch theo bên mình: dưới dạng tài liệu, trong lịch hoặc trên GPS.', + 'dayplan.exportDocument': 'Tài liệu', + 'dayplan.exportCalendar': 'Lịch', + 'dayplan.exportMaps': 'Bản đồ và GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': 'Kế hoạch ngày xuất dưới dạng PDF', 'dayplan.gpxTooltip': 'Xuất ra GPX cho bản đồ ngoại tuyến và thiết bị GPS', diff --git a/shared/src/i18n/zh-TW/dayplan.ts b/shared/src/i18n/zh-TW/dayplan.ts index 12f6a0a35..3c6f49d8d 100644 --- a/shared/src/i18n/zh-TW/dayplan.ts +++ b/shared/src/i18n/zh-TW/dayplan.ts @@ -49,6 +49,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': '未找到有座標的地點用於路線計算', 'dayplan.confirmed': '已確認', 'dayplan.pendingRes': '待確認', + 'dayplan.export': '匯出', + 'dayplan.exportIntro': '把行程帶在身邊:作為文件、放進行事曆,或存到 GPS 裝置。', + 'dayplan.exportDocument': '文件', + 'dayplan.exportCalendar': '行事曆', + 'dayplan.exportMaps': '地圖與 GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': '匯出當天計劃為 PDF', 'dayplan.gpxTooltip': '匯出為 GPX,用於離線地圖和 GPS 裝置', diff --git a/shared/src/i18n/zh/dayplan.ts b/shared/src/i18n/zh/dayplan.ts index 30c456b84..d55ff2819 100644 --- a/shared/src/i18n/zh/dayplan.ts +++ b/shared/src/i18n/zh/dayplan.ts @@ -49,6 +49,11 @@ const dayplan: TranslationStrings = { 'dayplan.toast.noGeoPlaces': '未找到有坐标的地点用于路线计算', 'dayplan.confirmed': '已确认', 'dayplan.pendingRes': '待确认', + 'dayplan.export': '导出', + 'dayplan.exportIntro': '把行程带在身边:作为文档、放进日历,或存到 GPS 设备。', + 'dayplan.exportDocument': '文档', + 'dayplan.exportCalendar': '日历', + 'dayplan.exportMaps': '地图与 GPS', 'dayplan.pdf': 'PDF', 'dayplan.pdfTooltip': '导出当天计划为 PDF', 'dayplan.gpxTooltip': '导出为 GPX,用于离线地图和 GPS 设备',