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 设备',