feat(packing): print, PDF and Markdown/CSV export of packing lists

The packing list bar gets an export menu next to the import button, both
now icon only. The menu prints the open view as a TREK styled page (trip,
dates, packed count, one card per category, bags), which the browser can
also save as a PDF, and downloads it as a Markdown checklist or as a CSV
in the exact format the import reads, bags included, so a list works as a
personal template without admin rights.

The import now also reads Markdown checklists: headings become
categories, "- [x]" marks an item packed, a "3x" prefix sets the
quantity and a "(200 g)" suffix the weight. The phone action sheet
offers the same exports through the shared usePackingExport hook.

The help center gets an export guide with new pictures, the import guide
and the wiki page describe the Markdown format, in all 27 languages.

Addresses discussions #1420 and #875.
This commit is contained in:
Maurice
2026-09-25 11:31:23 +02:00
parent 2a8071f642
commit 5c312a8497
106 changed files with 2148 additions and 192 deletions
@@ -546,8 +546,8 @@ describe('PackingListPanel', () => {
const textarea = screen.getByPlaceholderText(/Hygiene, Toothbrush/);
expect(textarea).toBeInTheDocument();
// "Load CSV/TXT" button is present inside the modal
expect(screen.getByText('Load CSV/TXT')).toBeInTheDocument();
// "Load CSV/TXT/MD" button is present inside the modal
expect(screen.getByText('Load CSV/TXT/MD')).toBeInTheDocument();
// Close by clicking backdrop (covers the onClick on the backdrop div)
const modalTitle = screen.getByText('Import Packing List');
@@ -1496,7 +1496,21 @@ describe('PackingListPanel', () => {
await waitFor(() => expect(createBody).toMatchObject({ name: 'New Bag' }));
});
it('FE-COMP-PACKING-069: Load CSV/TXT button clicks the hidden file input', async () => {
it('FE-COMP-PACKING-082: the import dialog explains the Markdown form, takes .md files and counts a pasted checklist (#875)', async () => {
const user = userEvent.setup();
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
await user.click(container.querySelector('svg.lucide-download')!.closest('button')!);
await screen.findByText('Import Packing List');
expect(screen.getByText(/A Markdown list works too/)).toBeInTheDocument();
expect(document.querySelector('input[type="file"]')).toHaveAttribute('accept', '.csv,.txt,.md,.markdown,text/markdown');
fireEvent.change(screen.getByPlaceholderText(/Hygiene, Toothbrush/), {
target: { value: '# Packing List\n## Clothing\n- [x] 3x Socks\n- [ ] Rain jacket (350 g)\nA note' },
});
expect(screen.getByRole('button', { name: 'Import 2' })).toBeInTheDocument();
});
it('FE-COMP-PACKING-069: Load CSV/TXT/MD button clicks the hidden file input', async () => {
const user = userEvent.setup();
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
@@ -1510,8 +1524,8 @@ describe('PackingListPanel', () => {
expect(fileInput).toBeTruthy();
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
// Click the "Load CSV/TXT" button
await user.click(screen.getByText('Load CSV/TXT'));
// Click the "Load CSV/TXT/MD" button
await user.click(screen.getByText('Load CSV/TXT/MD'));
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();