{caps.map((c, i) => (
{c.label}
))}
+ {/* This plugin talks to a service only the OPERATOR can name (a self-hosted
+ Gotify/ntfy), so its manifest can't list the host — the admin adds it.
+ Actionable, and warning-toned until at least one host exists, because
+ until then the plugin cannot reach anything and looks silently broken. */}
+ {p.operatorEgress && (
+
+ )}
{manifest.egress.map(h => (
{h}
))}
+ {/* The hosts above are NOT the whole story for this plugin: it talks to a
+ service only the operator can name, so its reach is whatever an admin
+ adds after install. Say so HERE — this is the pre-install review, and a
+ reviewer who reads only the host list would otherwise be misled. */}
+ {manifest.operatorEgress && (
+
+ {t('admin.plugins.operatorEgressPill')}
+
+ )}
`
}).join('')
+ // Plugin sections after the days — every value is host-vetted plain text and
+ // still escHtml'd here (same treatment as the core content above).
+ const pluginSectionsHtml = pluginSections.length === 0 ? '' : `
+
+ ${pluginSections.map(s => `
+
+
${escHtml(s.title)}
+ ${(s.paragraphs || []).map(p => `
${escHtml(p)}
`).join('')}
+ ${s.table ? `
+
+
${s.table.headers.map(h => `
${escHtml(h)}
`).join('')}
+ ${s.table.rows.map(row => `
${row.map(cell => `
${escHtml(cell)}
`).join('')}
`).join('')}
+
` : ''}
+
`).join('')}
+
`
+
const html = `
@@ -456,8 +484,10 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
.cover-stat-lbl { font-size: 9px; font-weight: 500; color: rgba(255,255,255,0.4); letter-spacing: 1px; margin-top: 4px; text-transform: uppercase; }
/* ── Day ───────────────────────────────────────── */
+ /* .day-section is a real
; its day header repeats on overflow pages. */
.page-break { page-break-before: always; }
- .day-header {
+ .day-section { width: 100%; border-collapse: collapse; table-layout: fixed; }
+ .day-header-bar {
background: #0f172a; padding: 11px 28px;
display: flex; align-items: center; gap: 8px;
}
@@ -538,6 +568,15 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
.empty-day { font-size: 9.5px; color: #cbd5e1; font-style: italic; text-align: center; padding: 14px 0; }
+ /* ── Plugin sections ───────────────────────────── */
+ .plugin-sections { padding: 16px 28px 6px; }
+ .plugin-section { margin-bottom: 16px; page-break-inside: avoid; }
+ .plugin-section-title { font-size: 12px; font-weight: 600; color: #1e293b; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid #e2e8f0; }
+ .plugin-section-text { font-size: 9.5px; color: #334155; line-height: 1.55; margin-bottom: 5px; }
+ .plugin-section-table { width: 100%; border-collapse: collapse; margin-top: 6px; }
+ .plugin-section-table th { font-size: 8px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; text-align: left; padding: 4px 8px; border-bottom: 1px solid #e2e8f0; }
+ .plugin-section-table td { font-size: 9px; color: #334155; padding: 4px 8px; border-bottom: 1px solid #f1f5f9; }
+
/* ── Print ─────────────────────────────────────── */
@media print {
body { margin: 0; }
@@ -591,7 +630,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${daysHtml}
-
+${pluginSectionsHtml}
`
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx
index 05467b623..881a2ca85 100644
--- a/client/src/components/Packing/PackingListPanel.test.tsx
+++ b/client/src/components/Packing/PackingListPanel.test.tsx
@@ -159,17 +159,17 @@ describe('PackingListPanel', () => {
expect(screen.getByText('Documents')).toBeInTheDocument();
});
- it('FE-COMP-PACKING-014: Add category button is shown', () => {
+ it('FE-COMP-PACKING-014: Add list button is shown', () => {
render();
- // The "Add category" button should be present in the toolbar
- expect(screen.getByText('Add category')).toBeInTheDocument();
+ // The "Add list" button should be present in the toolbar
+ expect(screen.getByText('Add list')).toBeInTheDocument();
});
it('FE-COMP-PACKING-015: clicking Add Category shows the category name input', async () => {
const user = userEvent.setup();
render();
- await user.click(screen.getByText('Add category'));
- await screen.findByPlaceholderText('Category name (e.g. Clothing)');
+ await user.click(screen.getByText('Add list'));
+ await screen.findByPlaceholderText('List name (e.g. Clothing)');
});
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
@@ -340,8 +340,8 @@ describe('PackingListPanel', () => {
);
render();
- await user.click(screen.getByText('Add category'));
- const input = await screen.findByPlaceholderText('Category name (e.g. Clothing)');
+ await user.click(screen.getByText('Add list'));
+ const input = await screen.findByPlaceholderText('List name (e.g. Clothing)');
await user.type(input, 'Valuables');
await user.keyboard('{Enter}');
@@ -496,7 +496,7 @@ describe('PackingListPanel', () => {
// Click "Rename" in the menu
await user.click(await screen.findByText('Rename'));
- // Category name input appears — type new name and save
+ // List name input appears — type new name and save
const catInput = screen.getByDisplayValue('Clothing');
await user.clear(catInput);
await user.type(catInput, 'Apparel');
@@ -858,8 +858,8 @@ describe('PackingListPanel', () => {
// It's rendered inside the action buttons group (sm:opacity-0 sm:group-hover:opacity-100)
// In jsdom, CSS classes don't apply so the buttons are accessible
// The dot button has a circle span inside with category color
- // Find all buttons with the 'Change Category' title
- const catChangeBtn = screen.getAllByTitle('Change Category');
+ // Find all buttons with the 'Move to List' title
+ const catChangeBtn = screen.getAllByTitle('Move to List');
expect(catChangeBtn.length).toBeGreaterThan(0);
await user.click(catChangeBtn[0]);
@@ -999,10 +999,10 @@ describe('PackingListPanel', () => {
);
const { container } = render();
- // Open context menu and click Delete Category
+ // Open context menu and click Delete List
const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button');
await user.click(moreBtn!);
- await user.click(await screen.findByText('Delete Category'));
+ await user.click(await screen.findByText('Delete List'));
await waitFor(() => {
expect(deletedIds).toContain(100);
@@ -1059,7 +1059,7 @@ describe('PackingListPanel', () => {
render();
// Use fireEvent (no pointer events) to open the category picker — avoids mouseLeave closing picker
- const catChangeBtns = screen.getAllByTitle('Change Category');
+ const catChangeBtns = screen.getAllByTitle('Move to List');
fireEvent.click(catChangeBtns[0]);
// Picker shows available categories — find and click the 'Documents' button (role=button, text=Documents)
diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx
index d20a92407..66145b21f 100644
--- a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx
+++ b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx
@@ -10,6 +10,7 @@ import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers'
import type { TripMember, CategoryAssignee } from './usePackingListPanel'
import { ArtikelZeile } from './PackingListPanelItemRow'
+import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
import GuestBadge from '../shared/GuestBadge'
interface KategorieGruppeProps {
@@ -44,6 +45,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
const [offen, setOffen] = useState(true)
const [dragId, setDragId] = useState(null)
const [overId, setOverId] = useState(null)
+ const contribFor = usePluginViewContributions('packing', tripId)
const handleReorderDrop = (targetId: number) => {
const from = dragId
@@ -265,18 +267,24 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
{offen && (
)}
+
+ {/* Reservation-detail plugin slots: sandboxed, scoped to this journey. The
+ card itself is clickable, so keep frame interactions from opening it. */}
+ {detailPlugins.length > 0 && (
+
+ )}
+ >
)
}
diff --git a/client/src/components/Plugins/PluginContributions.tsx b/client/src/components/Plugins/PluginContributions.tsx
new file mode 100644
index 000000000..d9b87920c
--- /dev/null
+++ b/client/src/components/Plugins/PluginContributions.tsx
@@ -0,0 +1,125 @@
+import { useCallback, useEffect, useState } from 'react'
+import { X } from 'lucide-react'
+import { pluginsApi, type ViewContribution } from '../../api/client'
+import { useToast } from '../shared/Toast'
+import PluginFrame from './PluginFrame'
+
+/**
+ * Host-rendered plugin contributions in a native planner view (#plugins,
+ * tableContributor hook). The server has already normalized + bounded every field
+ * (label/value length, http/https/mailto-only urls, enum tone/target), so this only
+ * renders trusted primitives — a column is text/link, an action is a labelled button
+ * that opens the plugin's sandboxed frame or calls one of its routes. Plugin-authored
+ * markup NEVER runs inline; it only ever runs inside the opaque-origin PluginFrame.
+ */
+export type ViewName = 'reservations' | 'transports' | 'places' | 'day' | 'costs' | 'packing' | 'files' | 'todos'
+type Column = Extract
+type Action = Extract
+
+const EMPTY: ViewContribution[] = []
+
+/** Fetch a view's contributions once (keyed on tripId) and bucket them by entityId.
+ * Fail-safe: any error yields an empty lookup, so a plugin can never block or break
+ * the native view — it can only ADD cells/buttons. */
+export function usePluginViewContributions(view: ViewName, tripId: string | number | null | undefined) {
+ const [byEntity, setByEntity] = useState
{editing ? (
- setLocalDays(e.target.value)}
+ onValueChange={setLocalDays}
onBlur={handleSave}
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') { setEditing(false); setLocalDays(s.vacation_days) } }}
autoFocus
diff --git a/client/src/components/Vacay/holidayRegions.ts b/client/src/components/Vacay/holidayRegions.ts
new file mode 100644
index 000000000..be06271a1
--- /dev/null
+++ b/client/src/components/Vacay/holidayRegions.ts
@@ -0,0 +1,34 @@
+import apiClient from '../../api/client'
+import iso31662 from 'iso-3166-2'
+
+// Loads the subdivision (state/region) options for a holiday-calendar country.
+//
+// The subdivision list is sourced from ISO 3166-2 (proper names, complete per country)
+// rather than inferred from which subdivisions happen to have a holiday this year — the
+// latter silently dropped states with no state-specific holiday (e.g. US-WA, issue #1456).
+// We only surface a list for countries that are actually region-partitioned, so countries
+// with only nationwide holidays keep showing no region picker (and allow a country-level
+// calendar, matching the server's applyHolidayCalendars behaviour).
+export async function fetchRegionOptions(country: string): Promise<{ value: string; label: string }[]> {
+ try {
+ const year = new Date().getFullYear()
+ const r = await apiClient.get(`/addons/vacay/holidays/${year}/${country}`)
+ const hasRegions = r.data.some(h => h.counties && h.counties.length > 0)
+ if (!hasRegions) return []
+
+ const opts = new Map() // ISO code -> display name
+ const sub = iso31662.country(country)?.sub || {}
+ for (const [code, info] of Object.entries(sub)) opts.set(code, info.name)
+
+ // Fall back to any nager county code ISO doesn't know about, so nothing regresses.
+ r.data.forEach(h => h.counties?.forEach(c => {
+ if (!opts.has(c)) opts.set(c, iso31662.subdivision(c)?.name || c.split('-')[1] || c)
+ }))
+
+ return [...opts]
+ .map(([value, label]) => ({ value, label }))
+ .sort((a, b) => a.label.localeCompare(b.label))
+ } catch {
+ return []
+ }
+}
diff --git a/client/src/components/shared/NumericInput.test.tsx b/client/src/components/shared/NumericInput.test.tsx
new file mode 100644
index 000000000..cda0d5ff4
--- /dev/null
+++ b/client/src/components/shared/NumericInput.test.tsx
@@ -0,0 +1,133 @@
+// FE-COMP-NUMINPUT-001 to FE-COMP-NUMINPUT-008
+//
+// #1513: tapping a pre-populated numeric field put the caret at the end, so the first
+// digit typed was appended rather than replacing the value — quantity 1 + "6" = "16".
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { useState } from 'react'
+import { NumericInput, type NumericMode } from './NumericInput'
+
+// select() is deferred a frame on purpose (see the component); run rAF synchronously.
+beforeEach(() => {
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
+ cb(0)
+ return 0
+ })
+})
+afterEach(() => vi.unstubAllGlobals())
+
+function Harness({ initial, mode }: { initial: string; mode?: NumericMode }) {
+ const [v, setV] = useState(initial)
+ return
+}
+
+describe('NumericInput', () => {
+ it('FE-COMP-NUMINPUT-001: selects the existing value on focus so a typed digit replaces it', () => {
+ render()
+ const input = screen.getByLabelText('field') as HTMLInputElement
+
+ fireEvent.focus(input)
+
+ expect(input.selectionStart).toBe(0)
+ expect(input.selectionEnd).toBe(1)
+ })
+
+ it('FE-COMP-NUMINPUT-002: selects a multi-digit value in full', () => {
+ render()
+ const input = screen.getByLabelText('field') as HTMLInputElement
+
+ fireEvent.focus(input)
+
+ expect(input.selectionStart).toBe(0)
+ expect(input.selectionEnd).toBe(3)
+ })
+
+ it('FE-COMP-NUMINPUT-003: is a text input with a numeric keypad, not type=number', () => {
+ // type=number silently mutates the value on scroll-wheel and ships spinner arrows.
+ render()
+ const input = screen.getByLabelText('field') as HTMLInputElement
+
+ expect(input.type).toBe('text')
+ expect(input.inputMode).toBe('numeric')
+ })
+
+ it('FE-COMP-NUMINPUT-004: integer mode strips non-digits', () => {
+ render()
+ const input = screen.getByLabelText('field') as HTMLInputElement
+
+ fireEvent.change(input, { target: { value: '1a2-b3' } })
+
+ expect(input.value).toBe('123')
+ })
+
+ it('FE-COMP-NUMINPUT-005: decimal mode keeps a dot and a comma (callers normalize the comma)', () => {
+ render()
+ const input = screen.getByLabelText('field') as HTMLInputElement
+
+ fireEvent.change(input, { target: { value: '12.50' } })
+ expect(input.value).toBe('12.50')
+
+ fireEvent.change(input, { target: { value: '12,50' } })
+ expect(input.value).toBe('12,50')
+
+ fireEvent.change(input, { target: { value: '1x2.5eur' } })
+ expect(input.value).toBe('12.5')
+ })
+
+ it('FE-COMP-NUMINPUT-006: decimal mode uses the decimal keypad', () => {
+ render()
+ expect((screen.getByLabelText('field') as HTMLInputElement).inputMode).toBe('decimal')
+ })
+
+ it('FE-COMP-NUMINPUT-007: signed mode keeps a leading minus (coordinates)', () => {
+ render()
+ const input = screen.getByLabelText('field') as HTMLInputElement
+
+ fireEvent.change(input, { target: { value: '-4.4215' } })
+ expect(input.value).toBe('-4.4215')
+
+ // A minus anywhere but the front is not a sign.
+ fireEvent.change(input, { target: { value: '36.72' } })
+ expect(input.value).toBe('36.72')
+ })
+
+ it('FE-COMP-NUMINPUT-009: typing inside the one-frame window is not swallowed by the deferred select', async () => {
+ // The deferred select() exists for WebKit, but it must not fire *after* the user has
+ // started typing: it would select the half-typed value and the next character would
+ // replace it, turning "48.853" into ".853". Any input cancels the pending select.
+ vi.unstubAllGlobals()
+ const frames: FrameRequestCallback[] = []
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
+ frames.push(cb)
+ return 0
+ })
+
+ render()
+ const input = screen.getByLabelText('field') as HTMLInputElement
+
+ fireEvent.focus(input)
+ // User types before the queued frame runs.
+ fireEvent.change(input, { target: { value: '4' } })
+ fireEvent.change(input, { target: { value: '48' } })
+
+ // Frame finally runs — must be a no-op now.
+ frames.forEach(cb => cb(0))
+
+ fireEvent.change(input, { target: { value: '48.853' } })
+
+ expect(input.value).toBe('48.853')
+ })
+
+ it('FE-COMP-NUMINPUT-008: a caller-supplied onFocus still runs alongside the select', () => {
+ const onFocus = vi.fn()
+ render(
+ {}} onFocus={onFocus} aria-label="field" />,
+ )
+ const input = screen.getByLabelText('field') as HTMLInputElement
+
+ fireEvent.focus(input)
+
+ expect(onFocus).toHaveBeenCalledTimes(1)
+ expect(input.selectionEnd).toBe(1)
+ })
+})
diff --git a/client/src/components/shared/NumericInput.tsx b/client/src/components/shared/NumericInput.tsx
new file mode 100644
index 000000000..a381d5865
--- /dev/null
+++ b/client/src/components/shared/NumericInput.tsx
@@ -0,0 +1,87 @@
+import { forwardRef, useRef, type InputHTMLAttributes } from 'react'
+
+export type NumericMode = 'integer' | 'decimal' | 'signed'
+
+const SANITIZERS: Record string> = {
+ // Digits only — quantities, weights, day counts.
+ integer: raw => raw.replace(/[^0-9]/g, ''),
+ // Digits plus a decimal separator. Both '.' and ',' pass through; callers already
+ // normalize the comma (see CostsPanel.onTotalChange) so a European keypad still works.
+ decimal: raw => raw.replace(/[^0-9.,]/g, ''),
+ // Signed decimal — coordinates. Keeps a leading '-'.
+ signed: raw => {
+ const negative = raw.trimStart().startsWith('-')
+ const digits = raw.replace(/[^0-9.]/g, '')
+ return negative ? `-${digits}` : digits
+ },
+}
+
+type Props = Omit, 'type' | 'onChange' | 'value'> & {
+ value: string | number | null | undefined
+ /** Receives the sanitized raw string. Callers keep their own state and commit logic. */
+ onValueChange: (value: string) => void
+ mode?: NumericMode
+ /** Escape hatch for a field that must not steal the caret (none today). */
+ selectOnFocus?: boolean
+}
+
+/**
+ * A numeric text input that replaces its contents when you type into it.
+ *
+ * Two things every numeric field in the app needs, and most were getting wrong (#1513):
+ *
+ * 1. **Select-on-focus.** Tapping a pre-populated field puts the caret at the end, so
+ * the first digit you type is *appended*: a quantity of 1 becomes 16 instead of 6.
+ * Selecting the value on focus makes the first keystroke replace it.
+ *
+ * We select twice: synchronously (works everywhere) and again on the next frame,
+ * because WebKit undoes a synchronous select() with its own post-focus caret
+ * placement — without the deferred pass, iOS silently keeps appending.
+ *
+ * The deferred pass is guarded. If a keystroke lands inside that one-frame window,
+ * re-selecting would swallow what was just typed: type "4" then "8", the frame
+ * fires and selects "48", and the next character *replaces* both — "48.853" comes
+ * out as ".853". So the first input cancels the pending select and we leave the
+ * caret alone.
+ *
+ * 2. **type="text" + inputMode**, not type="number". They render the same keypad, but
+ * type="number" also silently mutates the value on scroll-wheel over the field and
+ * ships spinner arrows. (setSelectionRange() also throws InvalidStateError on a
+ * number input — select() happens to be allowed, but the whole class is a trap.)
+ *
+ * Styling and commit semantics stay with the caller: some fields save on every keystroke,
+ * others on blur. This owns only the part that was uniformly broken.
+ */
+export const NumericInput = forwardRef(function NumericInput(
+ { value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, ...rest },
+ ref,
+) {
+ // Set while a deferred select() is queued; any input in that window cancels it.
+ const selectPending = useRef(false)
+
+ return (
+ {
+ selectPending.current = false
+ onValueChange(SANITIZERS[mode](e.target.value))
+ }}
+ onFocus={e => {
+ if (selectOnFocus) {
+ const el = e.currentTarget
+ el.select()
+ selectPending.current = true
+ requestAnimationFrame(() => {
+ if (selectPending.current && document.activeElement === el) el.select()
+ selectPending.current = false
+ })
+ }
+ onFocus?.(e)
+ }}
+ />
+ )
+})
diff --git a/client/src/components/shared/SlidingTabs.test.tsx b/client/src/components/shared/SlidingTabs.test.tsx
new file mode 100644
index 000000000..edbe457bb
--- /dev/null
+++ b/client/src/components/shared/SlidingTabs.test.tsx
@@ -0,0 +1,108 @@
+import { render, waitFor, act } from '../../../tests/helpers/render';
+import SlidingTabs from './SlidingTabs';
+
+const TABS = [
+ { id: 'a', label: 'Alpha' },
+ { id: 'b', label: 'Bravo' },
+] as const;
+
+// Query the absolutely-positioned pill (rendered with aria-hidden).
+function pill(): HTMLElement | null {
+ return document.querySelector('[aria-hidden="true"]');
+}
+
+describe('SlidingTabs', () => {
+ const onChange = vi.fn();
+ let rectSpy: ReturnType;
+ // Mutable geometry so we can simulate the active label reflowing (e.g. after a font swap).
+ let activeButtonRect: { left: number; width: number };
+ let capturedResizeCallbacks: ResizeObserverCallback[];
+ let resolveFonts: () => void;
+
+ beforeEach(() => {
+ onChange.mockClear();
+ activeButtonRect = { left: 20, width: 100 };
+ capturedResizeCallbacks = [];
+
+ // jsdom has no real layout — stub geometry. Container sits at left 0; buttons report
+ // the mutable rect (only the active button is ever measured).
+ rectSpy = vi
+ .spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ .mockImplementation(function (this: HTMLElement) {
+ const base = { top: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) };
+ if (this.tagName === 'BUTTON') {
+ return { ...base, left: activeButtonRect.left, width: activeButtonRect.width } as DOMRect;
+ }
+ return { ...base, left: 0, width: 300 } as DOMRect;
+ });
+
+ // Capturing ResizeObserver so tests can drive the resize path (global mock is a no-op).
+ class CapturingResizeObserver {
+ constructor(cb: ResizeObserverCallback) {
+ capturedResizeCallbacks.push(cb);
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ vi.stubGlobal('ResizeObserver', CapturingResizeObserver);
+
+ // Controllable document.fonts.ready.
+ Object.defineProperty(document, 'fonts', {
+ configurable: true,
+ value: {
+ ready: new Promise(resolve => {
+ resolveFonts = resolve;
+ }),
+ },
+ });
+ });
+
+ afterEach(() => {
+ rectSpy.mockRestore();
+ vi.unstubAllGlobals();
+ // @ts-expect-error — remove the stub so it doesn't leak into other suites.
+ delete document.fonts;
+ });
+
+ it('FE-COMP-TABS-001: positions the pill from the initial measurement', () => {
+ render();
+ const el = pill();
+ expect(el).toBeTruthy();
+ // left = activeRect.left(20) - containerRect.left(0) + scrollLeft(0)
+ expect(el!.style.left).toBe('20px');
+ expect(el!.style.width).toBe('100px');
+ });
+
+ it('FE-COMP-TABS-002: re-measures after document.fonts.ready resolves (reload font swap)', async () => {
+ render();
+ expect(pill()!.style.width).toBe('100px');
+
+ // Font settles → active (bold) label reflows wider and recentres.
+ activeButtonRect = { left: 12, width: 130 };
+ await act(async () => {
+ resolveFonts();
+ await Promise.resolve();
+ });
+
+ await waitFor(() => {
+ expect(pill()!.style.left).toBe('12px');
+ expect(pill()!.style.width).toBe('130px');
+ });
+ });
+
+ it('FE-COMP-TABS-003: re-measures when the ResizeObserver fires (window/container resize)', () => {
+ render();
+ expect(pill()!.style.width).toBe('100px');
+
+ activeButtonRect = { left: 40, width: 90 };
+ act(() => {
+ capturedResizeCallbacks.forEach(cb =>
+ cb([] as unknown as ResizeObserverEntry[], {} as ResizeObserver),
+ );
+ });
+
+ expect(pill()!.style.left).toBe('40px');
+ expect(pill()!.style.width).toBe('90px');
+ });
+});
diff --git a/client/src/components/shared/SlidingTabs.tsx b/client/src/components/shared/SlidingTabs.tsx
index 1c12a62d6..b6f41d114 100644
--- a/client/src/components/shared/SlidingTabs.tsx
+++ b/client/src/components/shared/SlidingTabs.tsx
@@ -30,16 +30,43 @@ export function SlidingTabs({
const [indicator, setIndicator] = useState<{ left: number; width: number; ready: boolean }>({ left: 0, width: 0, ready: false })
useLayoutEffect(() => {
- const active = tabRefs.current.get(activeTab)
- const container = containerRef.current
- if (!active || !container) return
- const containerRect = container.getBoundingClientRect()
- const activeRect = active.getBoundingClientRect()
- setIndicator({
- left: activeRect.left - containerRect.left + container.scrollLeft,
- width: activeRect.width,
- ready: true,
+ const measure = (): void => {
+ const active = tabRefs.current.get(activeTab)
+ const container = containerRef.current
+ if (!active || !container) return
+ const containerRect = container.getBoundingClientRect()
+ const activeRect = active.getBoundingClientRect()
+ const left = activeRect.left - containerRect.left + container.scrollLeft
+ const width = activeRect.width
+ setIndicator(prev =>
+ prev.ready && prev.left === left && prev.width === width
+ ? prev
+ : { left, width, ready: true },
+ )
+ }
+
+ measure()
+
+ // Re-measure once web fonts settle — on a reload the first measure runs against
+ // fallback-font metrics, so the (bold) active label reflows and the pill drifts.
+ let cancelled = false
+ document.fonts?.ready?.then(() => {
+ if (!cancelled) measure()
})
+
+ // Re-measure on layout/size changes: container (window resize) + active label reflow.
+ let ro: ResizeObserver | undefined
+ if (typeof ResizeObserver !== 'undefined') {
+ ro = new ResizeObserver(() => measure())
+ if (containerRef.current) ro.observe(containerRef.current)
+ const active = tabRefs.current.get(activeTab)
+ if (active) ro.observe(active)
+ }
+
+ return () => {
+ cancelled = true
+ ro?.disconnect()
+ }
}, [activeTab, tabs.length])
const padding = size === 'sm' ? '5px 12px' : '6px 14px'
diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts
index b1d550ad4..2ad5ec7ea 100644
--- a/client/src/hooks/useRouteCalculation.ts
+++ b/client/src/hooks/useRouteCalculation.ts
@@ -3,7 +3,7 @@ import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
import { calculateRouteWithLegs, withHotelBookends } from '../components/Map/RouteCalculator'
import { getTransportRouteEndpoints } from '../utils/dayMerge'
-import { getDayBookendHotels } from '../utils/dayOrder'
+import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../utils/dayOrder'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult, Accommodation } from '../types'
@@ -66,11 +66,11 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Build a unified list of places + transports sorted by effective position.
type Entry =
- | { kind: 'place'; lat: number; lng: number; pos: number }
+ | { kind: 'place'; lat: number; lng: number; pos: number; time: string | null }
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number }
const entries: Entry[] = [
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
- kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
+ kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index, time: a.place?.place_time ?? null,
})),
...dayTransports.map(r => {
const { from, to } = getTransportRouteEndpoints(r, dayId)
@@ -125,17 +125,19 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
}
const hotelPt = (a?: Accommodation) =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null
- // Only draw a hotel bookend when the leg is real. A hotel → first-stop leg holds
- // if the first stop is a place, or if you actually slept in that hotel last night;
- // on a day-1 arrival the morning hotel is just a check-in fallback and the first
- // waypoint is the transport's departure point, so [hotel → departure] is dropped
- // (#1321). Symmetrically, [last-stop → hotel] is dropped when you leave on a transport
- // in the evening and don't sleep in that hotel tonight.
+ // Only draw a hotel bookend when the leg is a real drive. You start/end the day at a hotel
+ // when you slept there / sleep there tonight; on the hotel's own check-in or check-out day
+ // the leg holds only when the edge stop is a PLACE timed after check-in / before check-out
+ // (you dropped bags first, or swung back before checking out). A place before check-in (an
+ // airport you reach first, #1465), a later "home" stop on the checkout day (#1465), or a
+ // transport endpoint on an arrival/departure day (#1321, S7) all draw no bookend.
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
const firstStop = entries.find(contributes)
const lastStop = [...entries].reverse().find(contributes)
- const drawMorning = firstStop?.kind === 'place' || !!bookends?.morningIsSleptHere
- const drawEvening = lastStop?.kind === 'place' || !!bookends?.eveningIsOvernight
+ const edgeInfo = (e?: Entry) =>
+ e ? { isPlace: e.kind === 'place', time: e.kind === 'place' ? e.time : null } : undefined
+ const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop))
+ const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop))
const runsWithHotel = withHotelBookends(
runs,
flatPts[0],
diff --git a/client/src/leafletCss.test.ts b/client/src/leafletCss.test.ts
new file mode 100644
index 000000000..e710e2960
--- /dev/null
+++ b/client/src/leafletCss.test.ts
@@ -0,0 +1,32 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, it, expect } from 'vitest'
+
+// Leaflet's stylesheet must be bundled, not loaded from unpkg: the service
+// worker cached the CDN response as opaque and the browser then rejected it,
+// leaving the Atlas and trip maps blank (#1497). These checks keep the CDN
+// reference from sneaking back in.
+describe('leaflet css self-hosting (#1497)', () => {
+ // Vitest runs with the client package as its root, so cwd is stable here.
+ const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8')
+
+ it('index.html no longer references unpkg', () => {
+ const html = read('index.html')
+ expect(html).not.toMatch(/unpkg\.com/i)
+ expect(html).not.toMatch(/leaflet/i)
+ })
+
+ it('main.tsx imports the bundled leaflet css before the app styles', () => {
+ const main = read('src/main.tsx')
+ const leafletAt = main.indexOf("import 'leaflet/dist/leaflet.css'")
+ const indexAt = main.indexOf("import './index.css'")
+ expect(leafletAt).toBeGreaterThan(-1)
+ expect(indexAt).toBeGreaterThan(-1)
+ // index.css overrides .leaflet-* styles, so order matters.
+ expect(leafletAt).toBeLessThan(indexAt)
+ })
+
+ it('the service worker has no runtime-caching rule for unpkg', () => {
+ expect(read('vite.config.js')).not.toMatch(/unpkg/i)
+ })
+})
diff --git a/client/src/main.tsx b/client/src/main.tsx
index fd5f5f013..c4f026869 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -13,15 +13,24 @@ import '@fontsource/poppins/700.css'
import '@fontsource/geist-sans/400.css'
import '@fontsource/geist-sans/500.css'
import '@fontsource/geist-sans/600.css'
+// Leaflet CSS bundled from node_modules instead of unpkg: the service worker
+// cached the CDN stylesheet as an opaque response, which the browser then
+// rejected, breaking the Atlas/trip maps (#1497). Bundling keeps it same-origin
+// and precached with the app shell. Must stay above index.css, which overrides
+// several .leaflet-* styles.
+import 'leaflet/dist/leaflet.css'
import './index.css'
// Native HTML5 drag-and-drop never fires on touch input, so the planner's place /
-// day reordering was dead on Android and iOS. This polyfill synthesises the standard
-// drag events from touch gestures over draggable elements — it only hooks touch, so
-// desktop mouse dragging is untouched (#1265).
-import 'drag-drop-touch'
+// day reordering was dead on Android and iOS. The `drag-drop-touch` polyfill synthesises
+// the standard drag events from touch gestures over draggable elements (#1265). It is
+// loaded only on non-mobile viewports: reorder DnD is disabled on mobile (#1432), where
+// the polyfill's synthetic click/dblclick otherwise turned quick one-finger map pans into
+// double-tap zooms (#1440). See utils/touchDragPolyfill.ts.
+import { maybeInstallTouchDragPolyfill } from './utils/touchDragPolyfill'
import { startConnectivityProbe } from './sync/connectivity'
import { requestPersistentStorage } from './sync/persistentStorage'
+maybeInstallTouchDragPolyfill()
startConnectivityProbe()
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
requestPersistentStorage()
diff --git a/client/src/pages/AdminPage.test.tsx b/client/src/pages/AdminPage.test.tsx
index d9afb7e92..f9be188c5 100644
--- a/client/src/pages/AdminPage.test.tsx
+++ b/client/src/pages/AdminPage.test.tsx
@@ -1,11 +1,10 @@
-import { describe, it, expect, beforeEach } from 'vitest';
-import { render, screen, waitFor, fireEvent, within } from '../../tests/helpers/render';
import { http, HttpResponse } from 'msw';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { buildAdmin } from '../../tests/helpers/factories';
import { server } from '../../tests/helpers/msw/server';
+import { fireEvent, render, screen, waitFor, within } from '../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
-import { buildUser, buildAdmin } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
-import { useAddonStore } from '../store/addonStore';
import AdminPage from './AdminPage';
// Mock heavy sub-panels to focus on page-level concerns
@@ -145,7 +144,7 @@ describe('AdminPage', () => {
}),
http.get('/api/admin/stats', () => {
return HttpResponse.json({ error: 'Forbidden' }, { status: 403 });
- }),
+ })
);
seedStore(useAuthStore, {
@@ -237,7 +236,7 @@ describe('AdminPage', () => {
await waitFor(() => {
expect(screen.getByText('42')).toBeInTheDocument(); // totalPlaces — unique on page
- expect(screen.getByText('8')).toBeInTheDocument(); // totalFiles — unique on page
+ expect(screen.getByText('8')).toBeInTheDocument(); // totalFiles — unique on page
});
});
});
@@ -302,7 +301,7 @@ describe('AdminPage', () => {
server.use(
http.get('/api/admin/version-check', () => {
return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' });
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -330,7 +329,7 @@ describe('AdminPage', () => {
return HttpResponse.json({
addons: [{ id: 'mcp', name: 'MCP Tokens', type: 'mcp', icon: '', enabled: true }],
});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -347,9 +346,9 @@ describe('AdminPage', () => {
let capturedBody: Record | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
- capturedBody = await request.json() as Record;
+ capturedBody = (await request.json()) as Record;
return HttpResponse.json({});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -462,9 +461,9 @@ describe('AdminPage', () => {
let capturedBody: Record | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
- capturedBody = await request.json() as Record;
+ capturedBody = (await request.json()) as Record;
return HttpResponse.json({});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -543,9 +542,18 @@ describe('AdminPage', () => {
server.use(
http.get('/api/admin/invites', () => {
return HttpResponse.json({
- invites: [{ id: 1, token: 'abcdef123456789', max_uses: 5, used_count: 0, expires_at: null, created_by_name: 'admin' }],
+ invites: [
+ {
+ id: 1,
+ token: 'abcdef123456789',
+ max_uses: 5,
+ used_count: 0,
+ expires_at: null,
+ created_by_name: 'admin',
+ },
+ ],
});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -573,9 +581,18 @@ describe('AdminPage', () => {
server.use(
http.get('/api/admin/invites', () => {
return HttpResponse.json({
- invites: [{ id: 1, token: 'abcdef123456789', max_uses: 5, used_count: 0, expires_at: null, created_by_name: 'admin' }],
+ invites: [
+ {
+ id: 1,
+ token: 'abcdef123456789',
+ max_uses: 5,
+ used_count: 0,
+ expires_at: null,
+ created_by_name: 'admin',
+ },
+ ],
});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -612,11 +629,26 @@ describe('AdminPage', () => {
http.get('/api/admin/notification-preferences', () => {
return HttpResponse.json({
event_types: ['version_available'],
- available_channels: { inapp: true, email: true },
+ channels: [
+ {
+ id: 'inapp',
+ source: 'builtin',
+ labelKey: 'settings.notificationPreferences.inapp',
+ active: true,
+ configured: true,
+ },
+ {
+ id: 'email',
+ source: 'builtin',
+ labelKey: 'settings.notificationPreferences.email',
+ active: true,
+ configured: true,
+ },
+ ],
implemented_combos: { version_available: ['inapp', 'email'] },
preferences: { version_available: { inapp: true, email: true } },
});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -640,7 +672,7 @@ describe('AdminPage', () => {
return HttpResponse.json({
addons: [{ id: 'mcp', name: 'MCP Tokens', type: 'mcp', icon: '', enabled: true }],
});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -659,7 +691,7 @@ describe('AdminPage', () => {
server.use(
http.get('/api/admin/version-check', () => {
return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' });
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -723,9 +755,9 @@ describe('AdminPage', () => {
let capturedBody: Record | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
- capturedBody = await request.json() as Record;
+ capturedBody = (await request.json()) as Record;
return HttpResponse.json({});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -755,7 +787,7 @@ describe('AdminPage', () => {
server.use(
http.put('/api/admin/oidc', async ({ request }) => {
return HttpResponse.json(await request.json());
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -788,9 +820,9 @@ describe('AdminPage', () => {
let capturedBody: Record | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
- capturedBody = await request.json() as Record;
+ capturedBody = (await request.json()) as Record;
return HttpResponse.json({});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -816,16 +848,16 @@ describe('AdminPage', () => {
let capturedBody: Record | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
- capturedBody = await request.json() as Record;
+ capturedBody = (await request.json()) as Record;
return HttpResponse.json({});
- }),
+ })
);
// Start with email enabled by seeding smtpValues
server.use(
http.get('/api/auth/app-settings', () => {
return HttpResponse.json({ notification_channels: 'email', smtp_host: 'mail.example.com' });
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -882,7 +914,7 @@ describe('AdminPage', () => {
server.use(
http.get('/api/admin/version-check', () => {
return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' });
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -942,8 +974,9 @@ describe('AdminPage', () => {
target: { value: 'alice-new@example.com' },
});
- expect((screen.getByDisplayValue('alice-new@example.com') as HTMLInputElement).value)
- .toBe('alice-new@example.com');
+ expect((screen.getByDisplayValue('alice-new@example.com') as HTMLInputElement).value).toBe(
+ 'alice-new@example.com'
+ );
});
});
@@ -954,7 +987,7 @@ describe('AdminPage', () => {
http.put('/api/auth/me/api-keys', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({ success: true });
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -986,7 +1019,7 @@ describe('AdminPage', () => {
http.put('/api/auth/me/api-keys', async ({ request }) => {
capturedBody = (await request.json()) as Record;
return HttpResponse.json({ success: true });
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1018,7 +1051,7 @@ describe('AdminPage', () => {
}),
http.get('/api/auth/validate-keys', () => {
return HttpResponse.json({ maps: true, weather: false });
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1079,7 +1112,7 @@ describe('AdminPage', () => {
http.delete('/api/admin/users/:id', ({ params }) => {
deletedId = params.id as string;
return HttpResponse.json({ success: true });
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1109,7 +1142,7 @@ describe('AdminPage', () => {
http.post('/api/admin/rotate-jwt-secret', () => {
rotateCalled = true;
return HttpResponse.json({ success: true });
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1142,7 +1175,7 @@ describe('AdminPage', () => {
smtp_host: 'mail.example.com',
smtp_skip_tls_verify: 'false',
});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1186,7 +1219,7 @@ describe('AdminPage', () => {
http.post('/api/notifications/test-smtp', () => {
testSmtpCalled = true;
return HttpResponse.json({ success: true });
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1223,7 +1256,7 @@ describe('AdminPage', () => {
http.put('/api/auth/app-settings', async () => {
appSettingsCalled = true;
return HttpResponse.json({});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1260,7 +1293,7 @@ describe('AdminPage', () => {
http.put('/api/auth/app-settings', async ({ request }) => {
savedPayload = await request.json();
return HttpResponse.json({});
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1292,7 +1325,15 @@ describe('AdminPage', () => {
http.get('/api/admin/notification-preferences', () => {
return HttpResponse.json({
event_types: ['trip.created'],
- available_channels: { email: true },
+ channels: [
+ {
+ id: 'email',
+ source: 'builtin',
+ labelKey: 'settings.notificationPreferences.email',
+ active: true,
+ configured: true,
+ },
+ ],
implemented_combos: { 'trip.created': ['email'] },
preferences: { 'trip.created': { email: true } },
});
@@ -1300,7 +1341,7 @@ describe('AdminPage', () => {
http.put('/api/admin/notification-preferences', async () => {
prefUpdateCalled = true;
return HttpResponse.json({ success: true });
- }),
+ })
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1346,7 +1387,9 @@ describe('AdminPage', () => {
// Discovery URL field
const discoveryInput = within(oidcCard!).getByPlaceholderText(/openid-configuration/i);
- fireEvent.change(discoveryInput, { target: { value: 'https://auth.example.com/.well-known/openid-configuration' } });
+ fireEvent.change(discoveryInput, {
+ target: { value: 'https://auth.example.com/.well-known/openid-configuration' },
+ });
// Client ID field
const clientIdLabel = within(oidcCard!).getByText('Client ID');
diff --git a/client/src/pages/CollectionsPage.test.tsx b/client/src/pages/CollectionsPage.test.tsx
new file mode 100644
index 000000000..dced7a40a
--- /dev/null
+++ b/client/src/pages/CollectionsPage.test.tsx
@@ -0,0 +1,123 @@
+// Regression coverage for issue #1485 — the "Add place" button vanished once a
+// collection held its first mappable place on a wide/desktop layout, because the
+// whole toolbar (which hosts the button) was gated on `!mapOverlay`. These tests
+// render the page against a mocked useCollections hook (per the Page pattern) and
+// assert the Add affordance stays reachable — and that there is exactly one of it.
+import { describe, it, expect, vi } from 'vitest'
+import React from 'react'
+import { render, screen } from '../../tests/helpers/render'
+import CollectionsPage from './CollectionsPage'
+
+// Stub every child so the test isolates CollectionsPage's own toolbar/branch JSX
+// (the Add button is rendered by the page itself, not by any child). This also
+// keeps the Leaflet/Mapbox map out of jsdom.
+vi.mock('../components/Layout/Navbar', () => ({ default: () => }))
+vi.mock('../components/Collections/ListsRail', () => ({ default: () => }))
+vi.mock('../components/Collections/ListEditorModal', () => ({ default: () => null }))
+vi.mock('../components/Collections/CollectionHero', () => ({ default: () => }))
+vi.mock('../components/Collections/CollectionList', () => ({ default: () => }))
+vi.mock('../components/Collections/CollectionFilterBar', () => ({ default: () => }))
+vi.mock('../components/Collections/CollectionMapPanel', () => ({ default: () => }))
+vi.mock('../components/Collections/CopyToTripModal', () => ({ default: () => null }))
+vi.mock('../components/Collections/MoveToListModal', () => ({ default: () => null }))
+vi.mock('../components/Collections/ShareCollectionModal', () => ({ default: () => null }))
+vi.mock('../components/Collections/AddPlaceToCollectionModal', () => ({ default: () => null }))
+vi.mock('../components/Collections/CollectionPlaceDetail', () => ({ default: () => null }))
+vi.mock('../components/Collections/LabelManager', () => ({ default: () => null }))
+vi.mock('../components/Collections/BulkAssignLabelModal', () => ({ default: () => null }))
+vi.mock('../components/shared/Modal', () => ({ default: () => null }))
+
+const mockUseCollections = vi.fn()
+vi.mock('./collections/useCollections', () => ({
+ useCollections: () => mockUseCollections(),
+}))
+
+// A complete-enough hook return; individual tests override the fields that matter.
+function makeHook(overrides: Record = {}) {
+ const noop = vi.fn()
+ return {
+ t: (k: string) => k,
+ language: 'en',
+ dark: false,
+ navigate: noop,
+ isWide: true,
+ heroRef: { current: null },
+ heroHeight: 0,
+ listColRef: { current: null },
+ listColRect: null,
+ categories: [],
+ collections: [{ id: 1, name: 'Test', is_owner: true }],
+ ownedLists: [{ id: 1, name: 'Test', is_owner: true }],
+ sharedLists: [],
+ activeCollection: { id: 1, name: 'Test', is_owner: true, color: '#6366f1' },
+ isAllSaved: false,
+ isOwner: true,
+ myRole: 'owner',
+ canEdit: true,
+ canDelete: true,
+ canShare: true,
+ shareMemberCount: 0,
+ activeId: 1,
+ places: [{ id: 10 }],
+ visiblePlaces: [{ id: 10 }],
+ mappable: [{ id: 10 }],
+ members: [],
+ incomingInvites: [],
+ counts: { all: 1, idea: 0, want: 1, visited: 0 },
+ view: 'list',
+ statusFilter: 'all',
+ categoryFilter: 'all',
+ categoryOptions: [],
+ search: '',
+ selectedPlaceId: null,
+ selectMode: false,
+ selectedIds: [],
+ labels: [],
+ labelFilter: [],
+ labelOptions: [],
+ loading: false,
+ placesLoading: false,
+ setView: noop, setStatusFilter: noop, setCategoryFilter: noop, setLabelFilter: noop,
+ setSearch: noop, setSelectedPlaceId: noop, setSelectMode: noop, toggleSelect: noop,
+ updatePlace: noop,
+ showLabelManager: false, setShowLabelManager: noop, labelPickerOpen: false, setLabelPickerOpen: noop,
+ handleCreateLabel: noop, handleUpdateLabel: noop, handleDeleteLabel: noop, handleBulkAssignLabels: noop, handleAssignPlaceLabels: noop,
+ editorTarget: null, setEditorTarget: noop, handleEditorCreated: noop,
+ showAddPlace: false, setShowAddPlace: noop, handlePlaceAdded: noop,
+ confirmDeleteList: null, setConfirmDeleteList: noop,
+ mobileRailOpen: false, setMobileRailOpen: noop,
+ showShare: false, setShowShare: noop, handleAfterLeave: noop,
+ selectedPlace: null, detailPlace: null, detailCategories: [], handleCloseDetail: noop,
+ handleDetailStatus: noop, handleDetailRemove: noop,
+ copyIds: null, openCopyForSelectedPlace: noop, openCopyForSelection: noop, closeCopy: noop, handleCopyToTrip: noop,
+ handleSelectList: noop, handleDeleteList: noop,
+ handleStatusChange: noop, handleDeletePlace: noop, handleDeleteSelected: noop,
+ handleAcceptInvite: noop, handleDeclineInvite: noop,
+ allVisibleSelected: false, handleSelectAll: noop,
+ listPickerMode: null, setListPickerMode: noop, handleMoveToList: noop, handleDuplicateToList: noop,
+ ...overrides,
+ }
+}
+
+describe('CollectionsPage — Add place button reachability (#1485)', () => {
+ it('keeps the Add button visible on a wide desktop once the list has a mappable place', () => {
+ // The exact bug: wide layout + one place with coordinates + editable list.
+ mockUseCollections.mockReturnValue(makeHook())
+ render()
+ const addButtons = screen.getAllByLabelText('collections.addPlace')
+ // Present, and exactly one — no duplicate map-overlay "+" alongside it.
+ expect(addButtons).toHaveLength(1)
+ })
+
+ it('does not show the Add button for a viewer who cannot edit', () => {
+ mockUseCollections.mockReturnValue(makeHook({ canEdit: false, isOwner: false, myRole: 'viewer' }))
+ render()
+ expect(screen.queryByLabelText('collections.addPlace')).toBeNull()
+ })
+
+ it('shows the Add button on an empty collection', () => {
+ mockUseCollections.mockReturnValue(makeHook({ places: [], visiblePlaces: [], mappable: [] }))
+ render()
+ expect(screen.getAllByLabelText('collections.addPlace')).toHaveLength(1)
+ })
+})
diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx
index 12726121f..6bd76e5cf 100644
--- a/client/src/pages/CollectionsPage.tsx
+++ b/client/src/pages/CollectionsPage.tsx
@@ -91,8 +91,6 @@ export default function CollectionsPage(): React.ReactElement {
overlay={overlay}
view={c.view}
onToggleView={toggleView}
- canAddPlace={canAddPlace}
- onAddPlace={() => c.setShowAddPlace(true)}
search={c.search}
onSearch={c.setSearch}
t={t}
@@ -202,7 +200,7 @@ export default function CollectionsPage(): React.ReactElement {
/>