-
handleChange('lat', e.target.value)}
+ onValueChange={v => handleChange('lat', v)}
onPaste={e => {
const text = e.clipboardData.getData('text').trim()
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
@@ -715,11 +715,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
placeholder={t('places.formLat')}
className="form-input"
/>
-
handleChange('lng', e.target.value)}
+ onValueChange={v => handleChange('lng', v)}
placeholder={t('places.formLng')}
className="form-input"
/>
diff --git a/client/src/components/Planner/PlacesSidebarList.tsx b/client/src/components/Planner/PlacesSidebarList.tsx
index 7492cc9df..9b2d75546 100644
--- a/client/src/components/Planner/PlacesSidebarList.tsx
+++ b/client/src/components/Planner/PlacesSidebarList.tsx
@@ -1,12 +1,16 @@
+import { Fragment } from 'react'
import { MemoPlaceRow } from './PlacesSidebarRow'
import type { SidebarState } from './usePlacesSidebar'
+import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
export function PlacesList(S: SidebarState) {
const {
filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace,
categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId,
- isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
+ isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, tripId,
} = S
+ // Plugin-contributed columns/actions for the places view, keyed by place id (#plugins).
+ const contribFor = usePluginViewContributions('places', tripId)
return (
onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
{filtered.length === 0 ? (
@@ -25,27 +29,32 @@ export function PlacesList(S: SidebarState) {
const isPlanned = plannedIds.has(place.id)
const inDay = inDaySet.has(place.id)
const isChecked = selectedIds.has(place.id)
+ const contributions = contribFor(place.id)
return (
-
+
+
+ {contributions.length > 0 && (
+
+ )}
+
)
})
)}
diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx
index f1f2a7bfc..69e8994bf 100644
--- a/client/src/components/Planner/ReservationModal.test.tsx
+++ b/client/src/components/Planner/ReservationModal.test.tsx
@@ -782,4 +782,62 @@ describe('ReservationModal', () => {
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].assignment_id).toBeNull();
});
+
+ // ── Hotel address persistence (issue #1496) ─────────────────────────────────
+
+ it('FE-PLANNER-RESMODAL-053: editing a hotel address sends the typed value even with a place linked', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ const place = buildPlace({ id: 5, name: 'Grand Hotel', address: 'Old Street 1' });
+ const days = [
+ buildDay({ id: 1, trip_id: 1, date: '2026-05-01', day_number: 1 }),
+ buildDay({ id: 2, trip_id: 1, date: '2026-05-02', day_number: 2 }),
+ ];
+ const res = buildReservation({
+ id: 3, title: 'Grand Hotel', type: 'hotel', accommodation_id: 8,
+ } as any);
+ const acc = { id: 8, trip_id: 1, place_id: 5, start_day_id: 1, end_day_id: 2 } as any;
+
+ render(
+
+ );
+
+ // Address field is pre-filled from the linked place
+ const addressInput = screen.getByPlaceholderText(/Address, Airport/i);
+ expect(addressInput).toHaveValue('Old Street 1');
+
+ await userEvent.clear(addressInput);
+ await userEvent.type(addressInput, 'New Street 2');
+ await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
+
+ await waitFor(() => expect(onSave).toHaveBeenCalled());
+ const saved = onSave.mock.calls[0][0];
+ // The typed address must reach the save handler — before #1496 it was
+ // dropped whenever a place was linked and the old address reappeared.
+ expect(saved.location).toBe('New Street 2');
+ expect(saved.create_accommodation?.address).toBe('New Street 2');
+ expect(saved.create_accommodation?.place_id).toBe(5);
+ });
+
+ it('FE-PLANNER-RESMODAL-054: hotel address is kept in location when no days or place are set', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render(
);
+
+ await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
+ await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Test');
+ await userEvent.type(screen.getByPlaceholderText(/Address, Airport/i), 'Main Road 3');
+ await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
+
+ await waitFor(() => expect(onSave).toHaveBeenCalled());
+ const saved = onSave.mock.calls[0][0];
+ // No day range → no accommodation, but the address must not be lost
+ expect(saved.create_accommodation).toBeUndefined();
+ expect(saved.location).toBe('Main Road 3');
+ });
});
diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx
index f8a775f1f..369cfb282 100644
--- a/client/src/components/Planner/ReservationModal.tsx
+++ b/client/src/components/Planner/ReservationModal.tsx
@@ -5,6 +5,7 @@ import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
+import AddressInput from './AddressInput'
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -147,7 +148,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
hotel_place_id: editAcc?.place_id || '',
hotel_start_day: editAcc?.start_day_id || '',
hotel_end_day: editAcc?.end_day_id || '',
- hotel_address: places.find(p => p.id == editAcc?.place_id)?.address || '',
+ // The linked place carries the address; reservations saved without a
+ // place (or before the accommodation existed) keep it in location.
+ hotel_address: places.find(p => p.id == editAcc?.place_id)?.address || reservation.location || '',
})
} else if (prefill) {
// Review-before-save: populate from a parsed import item, stay in create mode.
@@ -202,9 +205,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (!acc) return
setForm(prev => {
if (prev.hotel_place_id !== '' || prev.hotel_start_day !== '' || prev.hotel_end_day !== '') return prev
- return { ...prev, hotel_place_id: acc.place_id, hotel_start_day: acc.start_day_id, hotel_end_day: acc.end_day_id }
+ const accPlace = places.find(p => p.id == acc.place_id)
+ return { ...prev, hotel_place_id: acc.place_id, hotel_start_day: acc.start_day_id, hotel_end_day: acc.end_day_id, hotel_address: accPlace?.address || prev.hotel_address }
})
- }, [accommodations, isOpen, reservation])
+ }, [accommodations, isOpen, reservation, places])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
@@ -240,7 +244,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
title: form.title, type: form.type, status: form.status,
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
- location: form.location, confirmation_number: form.confirmation_number,
+ // Hotels show the address field instead of location — persist it on the
+ // reservation itself so it survives even without days/place (#1496).
+ location: form.type === 'hotel' ? form.hotel_address : form.location,
+ confirmation_number: form.confirmation_number,
notes: form.notes,
url: form.url,
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
@@ -260,6 +267,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
venue: (!form.hotel_place_id && (form.hotel_address || form.title))
? { name: form.title, address: form.hotel_address || null }
: null,
+ // The typed address, so the save handler can write it through to a
+ // linked place — an edited address used to be silently dropped (#1496).
+ address: form.hotel_address || null,
// Tolerate a single resolved end of the range (a one-night stay or a date
// that only matched one trip day) so the accommodation is still created.
start_day_id: form.hotel_start_day || form.hotel_end_day,
@@ -504,7 +514,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{form.type !== 'hotel' && (
)}
@@ -542,11 +552,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const p = places.find(pl => pl.id === value)
setForm(prev => {
const next = { ...prev, hotel_place_id: value }
- if (!value) {
- next.location = ''
- } else if (p) {
+ if (value && p) {
if (!prev.title) next.title = p.name
- if (!prev.location && p.address) next.location = p.address
+ // Show the picked hotel's address; keep a hand-typed one
+ // if the place has none.
+ next.hotel_address = p.address || prev.hotel_address
}
return next
})
@@ -609,7 +619,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
diff --git a/client/src/components/Planner/ReservationsPanel.test.tsx b/client/src/components/Planner/ReservationsPanel.test.tsx
index 9666b2bf2..a9b931bc2 100644
--- a/client/src/components/Planner/ReservationsPanel.test.tsx
+++ b/client/src/components/Planner/ReservationsPanel.test.tsx
@@ -436,4 +436,30 @@ describe('ReservationsPanel', () => {
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
expect(screen.getByText(/08:30/)).toBeInTheDocument();
});
+
+ // ── Chronological sorting (#1507) ───────────────────────────────────────────
+
+ it('FE-PLANNER-RESP-044: cards are ordered chronologically, day-linked entries by their day date', () => {
+ const day1 = buildDay({ id: 201, date: '2025-06-02', day_number: 2 } as any);
+ const day2 = buildDay({ id: 202, date: '2025-06-04', day_number: 4 } as any);
+ const dated = buildReservation({ title: 'Dated flight', type: 'flight', status: 'pending', reservation_time: '2025-06-03T09:00', created_at: '2025-05-01T00:00:00.000Z' });
+ const dayOnly = buildReservation({ title: 'Day-only train', type: 'train', status: 'pending', reservation_time: 'T10:00', day_id: 201, created_at: '2025-05-02T00:00:00.000Z' } as any);
+ const late = buildReservation({ title: 'Late bus', type: 'bus', status: 'pending', reservation_time: null, day_id: 202, created_at: '2025-05-03T00:00:00.000Z' } as any);
+ const undated = buildReservation({ title: 'Undated taxi', type: 'taxi', status: 'pending', created_at: '2025-04-01T00:00:00.000Z' });
+ render( );
+ const text = document.body.textContent || '';
+ const order = ['Day-only train', 'Dated flight', 'Late bus', 'Undated taxi'].map(t => text.indexOf(t));
+ expect(order.every(i => i >= 0)).toBe(true);
+ expect([...order].sort((a, b) => a - b)).toEqual(order);
+ });
+
+ it('FE-PLANNER-RESP-045: hotel sorts by its accommodation start day, not a stale day_id', () => {
+ const day1 = buildDay({ id: 301, date: '2025-06-01', day_number: 1 } as any);
+ const day2 = buildDay({ id: 302, date: '2025-06-05', day_number: 5 } as any);
+ const hotel = buildReservation({ title: 'Hotel stay', type: 'hotel', status: 'pending', day_id: 301, accommodation_start_day_id: 302 } as any);
+ const flight = buildReservation({ title: 'Mid flight', type: 'flight', status: 'pending', reservation_time: '2025-06-03T12:00' });
+ render( );
+ const text = document.body.textContent || '';
+ expect(text.indexOf('Mid flight')).toBeLessThan(text.indexOf('Hotel stay'));
+ });
});
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index a083b1eaf..39a7ed6b3 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -17,6 +17,10 @@ import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
+import type { ViewContribution } from '../../api/client'
+import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
+import { usePluginStore, type ActivePlugin } from '../../store/pluginStore'
+import PluginFrame from '../Plugins/PluginFrame'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
interface AssignmentLookupEntry {
@@ -76,9 +80,12 @@ interface ReservationCardProps {
assignmentLookup: Record
canEdit: boolean
days?: Day[]
+ contributions?: ViewContribution[]
+ /** Plugins that declared a reservation-detail slot — mounted at the card's foot, scoped to this reservation. */
+ detailPlugins?: ActivePlugin[]
}
-function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [] }: ReservationCardProps) {
+function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [], contributions = [], detailPlugins = [] }: ReservationCardProps) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
@@ -407,6 +414,19 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)}
+
+
+ {/* Reservation-detail plugin slots: sandboxed, scoped to this reservation. */}
+ {detailPlugins.length > 0 && (
+
+ {detailPlugins.map(p => (
+
+ ))}
+
+ )}
+
{/* Delete confirmation */}
{showDeleteConfirm && ReactDOM.createPortal(
void
onDelete: (id: number) => void
canEdit: boolean
+ tripId: number
+ contributions?: ViewContribution[]
+ detailPlugins?: ActivePlugin[]
}) {
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h'
@@ -566,6 +589,18 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit }: {
)}
+
+ {/* Reservation-detail plugin slots: sandboxed, scoped to this journey. The
+ card itself is clickable, so keep frame interactions from opening it. */}
+ {detailPlugins.length > 0 && (
+
e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
+ {detailPlugins.map(p => (
+
+ ))}
+
+ )}
{confirmOpen && ReactDOM.createPortal(
{ e.stopPropagation(); setConfirmOpen(false) }}>
e.stopPropagation()}>
@@ -599,9 +634,12 @@ interface ReservationsPanelProps {
onNavigateToFiles: () => void
titleKey?: string
addManualKey?: string
+ /** Which plugin view this panel represents — the transports tab is its own
+ * contribution view, the bookings tab stays 'reservations'. */
+ contributionView?: 'reservations' | 'transports'
}
-export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
+export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual', contributionView = 'reservations' }: ReservationsPanelProps) {
const { t, locale } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
@@ -609,6 +647,12 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
const storageKey = `trek-reservation-filters-${tripId}`
+ // Plugin-contributed columns/actions for this view, keyed by reservation id (#plugins).
+ // The bookings and transports tabs share this panel but are distinct plugin views.
+ const contribFor = usePluginViewContributions(contributionView, tripId)
+ // Plugins that declared a reservation-detail slot mount at the foot of each card,
+ // scoped to that reservation. Filtered inline like the place-/day-detail sites.
+ const reservationDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'reservation-detail')
const [typeFilters, setTypeFilters] = useState
>(() => {
try {
const saved = sessionStorage.getItem(storageKey)
@@ -631,10 +675,37 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)),
[reservations, typeFilters])
+ // Chronological order (#1507): day-linked transports often carry no date in
+ // reservation_time, so resolve each entry to an effective departure datetime —
+ // the stamped date when there is one, else the linked day's date (the
+ // accommodation start day for hotels, matching the card label). Entries
+ // without any resolvable date sink to the bottom; creation order breaks ties.
+ const sorted = useMemo(() => {
+ const dayDates = new Map(days.map(d => [d.id, d.date]))
+ const sortKey = (r: Reservation): string | null => {
+ const { date, time } = splitReservationDateTime(r.reservation_time)
+ const dayId = r.type === 'hotel' ? (r.accommodation_start_day_id ?? r.day_id) : r.day_id
+ const effectiveDate = date ?? (dayId != null ? dayDates.get(dayId) : null)
+ if (!effectiveDate) return null
+ return `${effectiveDate}T${time ?? '00:00'}`
+ }
+ return filtered
+ .map(r => ({ r, key: sortKey(r) }))
+ .sort((a, b) => {
+ if (a.key !== b.key) {
+ if (a.key === null) return 1
+ if (b.key === null) return -1
+ return a.key < b.key ? -1 : 1
+ }
+ return (a.r.created_at ?? '').localeCompare(b.r.created_at ?? '')
+ })
+ .map(({ r }) => r)
+ }, [filtered, days])
+
// Automated public transit (#1065) gets its own section — journeys planned via
// the transit search live alongside manual transports without mixing in.
- const transitEntries = filtered.filter(r => r.type === 'transit')
- const nonTransit = filtered.filter(r => r.type !== 'transit')
+ const transitEntries = sorted.filter(r => r.type === 'transit')
+ const nonTransit = sorted.filter(r => r.type !== 'transit')
const allPending = nonTransit.filter(r => r.status !== 'confirmed')
const allConfirmed = nonTransit.filter(r => r.status === 'confirmed')
const total = filtered.length
@@ -776,17 +847,17 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
<>
{transitEntries.length > 0 && (
- {transitEntries.map(r => )}
+ {transitEntries.map(r => )}
)}
{allPending.length > 0 && (
- {allPending.map(r => )}
+ {allPending.map(r => )}
)}
{allConfirmed.length > 0 && (
- {allConfirmed.map(r => )}
+ {allConfirmed.map(r => )}
)}
>
diff --git a/client/src/components/Planner/TransitSearchPanel.test.tsx b/client/src/components/Planner/TransitSearchPanel.test.tsx
index fc3213f4b..e5b079407 100644
--- a/client/src/components/Planner/TransitSearchPanel.test.tsx
+++ b/client/src/components/Planner/TransitSearchPanel.test.tsx
@@ -135,6 +135,43 @@ describe('TransitSearchPanel', () => {
expect(cards[0].textContent).toContain('08:40')
})
+ // Cross-timezone route (#1479): origin Europe/Berlin (UTC+2 in June),
+ // destination Europe/London (UTC+1). Same wall-clock 09:00 resolves to a
+ // different UTC instant depending on which end the user anchors to.
+ async function pickFromAndLondon(user: ReturnType) {
+ const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…')
+ await user.click(fromInput)
+ await user.click(await screen.findByText('Fernsehturm'))
+
+ transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'London Victoria', lat: 51.5074, lng: -0.1278, type: 'STOP', area: 'England' }] })
+ await user.click(toInput)
+ await user.type(toInput, 'Lon')
+ await user.click(await screen.findByText(/London Victoria/))
+ }
+
+ it('FE-PLANNER-TRANSIT-007: depart-by anchors the entered time to the origin timezone', async () => {
+ const user = userEvent.setup()
+ transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
+ render( )
+ await pickFromAndLondon(user)
+ await user.click(screen.getByRole('button', { name: /^Search$/ }))
+ await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
+ // Default 09:00 in Berlin (UTC+2) → 07:00Z.
+ expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: false, time: '2025-06-01T07:00:00.000Z' })
+ })
+
+ it('FE-PLANNER-TRANSIT-008: arrive-by anchors the entered time to the destination timezone', async () => {
+ const user = userEvent.setup()
+ transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
+ render( )
+ await pickFromAndLondon(user)
+ await user.click(screen.getByRole('button', { name: 'Arrive' }))
+ await user.click(screen.getByRole('button', { name: /^Search$/ }))
+ await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
+ // 09:00 arrival in London (UTC+1) → 08:00Z, not 07:00Z.
+ expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: true, time: '2025-06-01T08:00:00.000Z' })
+ })
+
it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => {
const user = userEvent.setup()
render( )
diff --git a/client/src/components/Planner/TransitSearchPanel.tsx b/client/src/components/Planner/TransitSearchPanel.tsx
index 3a7a315d3..0ea432546 100644
--- a/client/src/components/Planner/TransitSearchPanel.tsx
+++ b/client/src/components/Planner/TransitSearchPanel.tsx
@@ -368,7 +368,10 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
setExpandedIdx(null)
try {
const tzFrom = tzAt(from.lat, from.lng)
- const timeIso = localToUtcIso(day.date, time, tzFrom)
+ const tzTo = tzAt(to.lat, to.lng)
+ // Depart-by anchors the entered time at the origin; arrive-by anchors it at
+ // the destination (#1479), so each mode must convert with the matching zone.
+ const timeIso = localToUtcIso(day.date, time, arriveBy ? tzTo : tzFrom)
const allModes = activeModes.size === MODE_GROUPS.length
const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',')
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes })
diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx
index 9cd153469..ad45ea282 100644
--- a/client/src/components/Planner/TransportModal.test.tsx
+++ b/client/src/components/Planner/TransportModal.test.tsx
@@ -11,6 +11,8 @@ import {
buildUser,
buildTrip,
buildDay,
+ buildPlace,
+ buildAssignment,
buildReservation,
buildTripFile,
} from '../../../tests/helpers/factories';
@@ -391,6 +393,25 @@ describe('TransportModal', () => {
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
});
+ it('FE-PLANNER-TRANSMODAL-028: automated quick picks only offer the chosen day\'s places (#1460)', async () => {
+ const days = [
+ buildDay({ id: 10, date: '2025-06-01' }),
+ buildDay({ id: 11, date: '2025-06-02' }),
+ ];
+ const louvre = buildPlace({ id: 1, name: 'Louvre' });
+ const eiffel = buildPlace({ id: 2, name: 'Eiffel Tower' });
+ const assignments = {
+ '10': [buildAssignment({ day_id: 10, place_id: louvre.id, place: louvre })],
+ '11': [buildAssignment({ day_id: 11, place_id: eiffel.id, place: eiffel })],
+ };
+ render( );
+ // Focusing the "from" field opens the quick picks — day 1's place only.
+ const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…');
+ await userEvent.click(fromInput);
+ expect(screen.getByText('Louvre')).toBeInTheDocument();
+ expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument();
+ });
+
it('FE-PLANNER-TRANSMODAL-024: editing shows no Manual/Automated switch', () => {
const res = buildReservation({ title: 'My Train', type: 'train' });
render( );
diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx
index 12bcdfab4..8a01addaf 100644
--- a/client/src/components/Planner/TransportModal.tsx
+++ b/client/src/components/Planner/TransportModal.tsx
@@ -13,7 +13,7 @@ import { useAddonStore } from '../../store/addonStore'
import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
-import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
+import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types'
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -151,6 +151,8 @@ interface TransportModalProps {
prefill?: BookingReviewDraft | null
/** Data for the Automated (public transit) mode's quick picks. */
places?: Place[]
+ /** Day→assignments map, used to scope the quick picks to the chosen day (#1460). */
+ assignments?: AssignmentsMap
accommodations?: Accommodation[]
/** Open directly in the Automated public-transit mode (day-header tram button, "change route"). */
initialAutomated?: boolean
@@ -158,7 +160,7 @@ interface TransportModalProps {
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null } | null
}
-export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], accommodations = [], initialAutomated = false, transitPrefill = null }: TransportModalProps) {
+export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
@@ -639,11 +641,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
{(() => {
const transitDay = days.find(d => d.id === Number(form.start_day_id))
if (!transitDay) return {t('transit.pickDay')}
+ // Quick picks offer the chosen day's itinerary, not the whole trip (#1460).
+ const dayPlaces = (assignments[String(transitDay.id)] || [])
+ .slice().sort((a, b) => a.order_index - b.order_index)
+ .map(a => places.find(p => p.id === a.place_id))
+ .filter((p): p is Place => p != null)
return (
onSave(payload as Record & { title: string })}
initialFrom={transitPrefill?.from ?? null}
diff --git a/client/src/components/Planner/TripWarningsBanner.tsx b/client/src/components/Planner/TripWarningsBanner.tsx
index a5c6b4cbf..e32f5d072 100644
--- a/client/src/components/Planner/TripWarningsBanner.tsx
+++ b/client/src/components/Planner/TripWarningsBanner.tsx
@@ -1,11 +1,18 @@
import { useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
import { AlertTriangle, Info, AlertCircle } from 'lucide-react'
import { pluginsApi } from '../../api/client'
+import { usePluginStore } from '../../store/pluginStore'
/**
* Shows validation/warning contributions from `warningProvider` plugins (#1429).
* Self-contained + fail-safe: the server skips any slow/failing provider, so this
* only ever adds rows; it renders nothing (and takes no space) when there are none.
+ *
+ * Placement: a warning from a plugin that owns a trip-page tab in this planner
+ * renders as a compact chip in the navbar centre (click jumps to that tab); all
+ * other warnings float above the content at the BOTTOM of the planner, so neither
+ * kind ever covers the map toolbar or displaces the working area up top.
*/
type Warning = { pluginId: string; level: 'info' | 'warning' | 'error'; message: string }
@@ -15,8 +22,14 @@ const STYLE = {
error: { Icon: AlertCircle, color: 'var(--danger)', bg: 'var(--danger-soft)' },
} as const
-export default function TripWarningsBanner({ tripId }: { tripId: number }) {
+export default function TripWarningsBanner({ tripId, onOpenPluginTab }: { tripId: number; onOpenPluginTab?: (pluginId: string) => void }) {
const [warnings, setWarnings] = useState([])
+ const [navSlot, setNavSlot] = useState(null)
+ const [isDesktop, setIsDesktop] = useState(
+ () => typeof window !== 'undefined' && (window.matchMedia?.('(min-width: 768px)')?.matches ?? true)
+ )
+ const plugins = usePluginStore((s) => s.plugins)
+
useEffect(() => {
if (!Number.isFinite(tripId)) { setWarnings([]); return }
let cancelled = false
@@ -26,21 +39,62 @@ export default function TripWarningsBanner({ tripId }: { tripId: number }) {
return () => { cancelled = true }
}, [tripId])
+ // The navbar renders in the same commit as the planner, so the slot exists by
+ // the time effects run; it is display:none'd with the navbar below md.
+ useEffect(() => { setNavSlot(document.getElementById('trek-nav-center-slot')) }, [])
+ useEffect(() => {
+ const mq = window.matchMedia?.('(min-width: 768px)')
+ if (!mq) return
+ const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches)
+ mq.addEventListener('change', handler)
+ return () => mq.removeEventListener('change', handler)
+ }, [])
+
if (warnings.length === 0) return null
- // A non-blocking overlay pinned to the top of the (fixed) planner content region:
- // the wrapper ignores pointer events so it never covers the map/panels, and only
- // the warning pills themselves are interactive.
+
+ const tripPageIds = new Set(plugins.filter((p) => p.type === 'trip-page').map((p) => p.id))
+ const chips = navSlot && isDesktop ? warnings.filter((w) => tripPageIds.has(w.pluginId)) : []
+ const floating = warnings.filter((w) => !chips.includes(w))
+
return (
-
- {warnings.map((w, i) => {
- const s = STYLE[w.level] ?? STYLE.warning
- return (
-
-
- {w.message}
-
- )
- })}
-
+ <>
+ {navSlot && chips.length > 0 && createPortal(
+ chips.map((w, i) => {
+ const s = STYLE[w.level] ?? STYLE.warning
+ return (
+ onOpenPluginTab(w.pluginId) : undefined}
+ title={w.message}
+ style={{
+ pointerEvents: 'auto', display: 'flex', alignItems: 'center', gap: 6,
+ padding: '4px 10px', borderRadius: 999, border: 'none',
+ background: s.bg, color: s.color, cursor: onOpenPluginTab ? 'pointer' : 'default',
+ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
+ maxWidth: 'min(44vw, 520px)', minWidth: 0,
+ }}
+ >
+
+ {w.message}
+
+ )
+ }),
+ navSlot
+ )}
+ {floating.length > 0 && (
+
+ {floating.map((w, i) => {
+ const s = STYLE[w.level] ?? STYLE.warning
+ return (
+
+
+ {w.message}
+
+ )
+ })}
+
+ )}
+ >
)
}
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>(new Map())
+ useEffect(() => {
+ if (tripId == null) { setByEntity(new Map()); return }
+ let cancelled = false
+ pluginsApi.viewContributions(view, tripId)
+ .then(({ contributions }) => {
+ if (cancelled) return
+ const m = new Map()
+ for (const c of contributions || []) {
+ const arr = m.get(c.entityId)
+ if (arr) arr.push(c); else m.set(c.entityId, [c])
+ }
+ setByEntity(m)
+ })
+ .catch(() => { if (!cancelled) setByEntity(new Map()) })
+ return () => { cancelled = true }
+ }, [view, tripId])
+ return useCallback((entityId: number): ViewContribution[] => byEntity.get(entityId) ?? EMPTY, [byEntity])
+}
+
+const TONE_CLASS: Record = {
+ default: 'text-content-muted',
+ success: 'text-success',
+ warn: 'text-warning',
+ danger: 'text-danger',
+}
+
+/** Extra read-only cells (label + value/link) for one entity, in the card/row body. */
+export function PluginColumns({ items }: { items: ViewContribution[] }) {
+ const cols = items.filter((c): c is Column => c.kind === 'column')
+ if (!cols.length) return null
+ return (
+
+ {cols.map((c) => (
+
+ ))}
+
+ )
+}
+
+/** The plugin-contributed columns + actions strip appended to the bottom of an
+ * entity row/card. Renders nothing (zero change) when no plugin contributes. */
+export function PluginCardFooter({ items, tripId }: { items: ViewContribution[]; tripId: number }) {
+ if (!items.length) return null
+ return (
+
+ )
+}
+
+/** Labelled action buttons for one entity — a route action calls the plugin route;
+ * a frame action opens the plugin's sandboxed UI in a modal. */
+export function PluginActions({ items, tripId, className }: { items: ViewContribution[]; tripId: string | number | null; className?: string }) {
+ const actions = items.filter((c): c is Action => c.kind === 'action')
+ const [frame, setFrame] = useState<{ pluginId: string; label: string } | null>(null)
+ const toast = useToast()
+ if (!actions.length) return null
+
+ const run = (a: Action, e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (a.target.kind === 'frame') { setFrame({ pluginId: a.pluginId, label: a.label }); return }
+ void pluginsApi.invoke(a.pluginId, a.target.sub, { method: a.target.method })
+ .then(() => toast.success(a.label))
+ .catch(() => toast.error(a.label))
+ }
+
+ return (
+ <>
+ {actions.map((a) => (
+ run(a, e)}
+ title={a.label}
+ className={className ?? 'px-2 h-7 inline-flex items-center rounded-lg border border-edge bg-surface-card text-xs text-content-muted hover:text-content hover:border-content-faint transition-colors'}
+ >
+ {a.label}
+
+ ))}
+ {frame && (
+ setFrame(null)}>
+
e.stopPropagation()}>
+
+ {frame.label}
+ setFrame(null)} className="text-content-muted hover:text-content">
+
+
+
+
+ )}
+ >
+ )
+}
diff --git a/client/src/components/Plugins/PluginFrame.test.tsx b/client/src/components/Plugins/PluginFrame.test.tsx
index e9afc6bcf..5aa49510e 100644
--- a/client/src/components/Plugins/PluginFrame.test.tsx
+++ b/client/src/components/Plugins/PluginFrame.test.tsx
@@ -1,17 +1,22 @@
-// FE-PLUGINS-FRAME-001 to 004
-import { render, cleanup, waitFor } from '@testing-library/react';
+// FE-PLUGINS-FRAME-001 to 012
+import { render, cleanup, waitFor, fireEvent, screen, act } from '@testing-library/react';
import PluginFrame from './PluginFrame';
const navigate = vi.fn();
const toast = { info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn() };
const invoke = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true }));
+const wsListeners = new Set<(ev: Record) => void>();
vi.mock('react-router-dom', () => ({ useNavigate: () => navigate }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
-vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en' }) }));
+vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en', t: (k: string) => k }) }));
vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7, username: 'ada', avatar_url: null, role: 'admin' } }) }));
vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (sel: (s: unknown) => unknown) => sel({ settings: { default_currency: 'EUR', time_format: '24h', distance_unit: 'metric', temperature_unit: 'celsius' } }) }));
vi.mock('../../api/client', () => ({ pluginsApi: { invoke: (id: string, sub: string, init?: unknown) => invoke(id, sub, init) } }));
+vi.mock('../../api/websocket', () => ({
+ addListener: (fn: (ev: Record) => void) => wsListeners.add(fn),
+ removeListener: (fn: (ev: Record) => void) => wsListeners.delete(fn),
+}));
function fromFrame(frame: HTMLIFrameElement, data: unknown) {
window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data } as MessageEventInit));
@@ -22,6 +27,7 @@ afterEach(() => {
navigate.mockClear();
Object.values(toast).forEach((f) => f.mockClear());
invoke.mockClear();
+ wsListeners.clear();
});
describe('PluginFrame', () => {
@@ -83,6 +89,37 @@ describe('PluginFrame', () => {
expect(JSON.stringify(ctx)).not.toContain('@'); // no email leaked
});
+ it('FE-PLUGINS-FRAME-008: a day-detail host passes the open day (and place stays null)', () => {
+ const { container } = render( );
+ const iframe = container.querySelector('iframe')!;
+ const posted: Array> = [];
+ (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record);
+
+ fromFrame(iframe, { type: 'trek:context:request' });
+
+ const ctx = posted.find((m) => m.type === 'trek:context') as Record | undefined;
+ expect(ctx).toBeTruthy();
+ expect(ctx!.tripId).toBe('1');
+ expect(ctx!.dayId).toBe('12');
+ expect(ctx!.placeId).toBeNull();
+ });
+
+ it('FE-PLUGINS-FRAME-014: a reservation-detail host passes the open reservation (and day/place stay null)', () => {
+ const { container } = render( );
+ const iframe = container.querySelector('iframe')!;
+ const posted: Array> = [];
+ (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record);
+
+ fromFrame(iframe, { type: 'trek:context:request' });
+
+ const ctx = posted.find((m) => m.type === 'trek:context') as Record | undefined;
+ expect(ctx).toBeTruthy();
+ expect(ctx!.tripId).toBe('1');
+ expect(ctx!.reservationId).toBe('88');
+ expect(ctx!.dayId).toBeNull();
+ expect(ctx!.placeId).toBeNull();
+ });
+
it('FE-PLUGINS-FRAME-006: context mirrors the host appearance state (scheme/density/flags)', () => {
const { container } = render( );
const iframe = container.querySelector('iframe')!;
@@ -101,4 +138,89 @@ describe('PluginFrame', () => {
reducedMotion: false,
});
});
+
+ it('FE-PLUGINS-FRAME-007: fill mode pins the frame to 100% height and ignores trek:resize', () => {
+ const { container } = render( );
+ const iframe = container.querySelector('iframe')!;
+ act(() => { fromFrame(iframe, { type: 'trek:resize', height: 480 }); });
+ expect(iframe.style.height).toBe('100%');
+ });
+
+ it('FE-PLUGINS-FRAME-008: without fill, trek:resize drives the frame height (widget self-sizing)', () => {
+ const { container } = render( );
+ const iframe = container.querySelector('iframe')!;
+ act(() => { fromFrame(iframe, { type: 'trek:resize', height: 480 }); });
+ expect(iframe.style.height).toBe('480px');
+ });
+
+ it('FE-PLUGINS-FRAME-009: trek:confirm renders the native dialog and answers over the bridge', async () => {
+ const { container } = render( );
+ const iframe = container.querySelector('iframe')!;
+ const posted: Array> = [];
+ (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record);
+
+ fromFrame(iframe, { type: 'trek:confirm', requestId: 'c1', message: 'Delete everything?', confirmLabel: 'Yes, wipe it' });
+ const confirmBtn = await screen.findByText('Yes, wipe it');
+ fireEvent.click(confirmBtn);
+
+ const result = posted.find((m) => m.type === 'trek:confirm:result') as Record | undefined;
+ expect(result).toMatchObject({ requestId: 'c1', confirmed: true });
+ });
+
+ it('FE-PLUGINS-FRAME-010: trek:openExternal opens only real web URLs in a noopener tab', () => {
+ const open = vi.spyOn(window, 'open').mockReturnValue(null);
+ const { container } = render( );
+ const iframe = container.querySelector('iframe')!;
+
+ fromFrame(iframe, { type: 'trek:openExternal', url: 'javascript:alert(1)' });
+ fromFrame(iframe, { type: 'trek:openExternal', url: 'not a url' });
+ expect(open).not.toHaveBeenCalled();
+
+ fromFrame(iframe, { type: 'trek:openExternal', url: 'https://example.com/docs' });
+ expect(open).toHaveBeenCalledWith('https://example.com/docs', '_blank', 'noopener,noreferrer');
+ open.mockRestore();
+ });
+
+ it('FE-PLUGINS-FRAME-011: forwards core-event names for the trip in view, payload-free', () => {
+ const { container } = render( );
+ const iframe = container.querySelector('iframe')!;
+ const posted: Array> = [];
+ (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record);
+ // The bridge only forwards to the original document (first load).
+ fireEvent.load(iframe);
+
+ expect(wsListeners.size).toBe(1);
+ const emit = [...wsListeners][0];
+ emit({ type: 'place_created', tripId: 99, place: { secret: true } }); // other trip -> dropped
+ emit({ type: 'place_created', tripId: 42, place: { secret: true } });
+
+ const events = posted.filter((m) => m.type === 'trek:event');
+ expect(events).toHaveLength(1);
+ expect(events[0]).toMatchObject({ event: 'place_created', tripId: '42' });
+ expect(JSON.stringify(events[0])).not.toContain('secret'); // names only, never payloads
+ });
+
+ it('FE-PLUGINS-FRAME-012: trek:notify clamps a plugin-supplied duration', () => {
+ const { container } = render( );
+ const iframe = container.querySelector('iframe')!;
+ fromFrame(iframe, { type: 'trek:notify', level: 'info', message: 'hi', duration: 999999 });
+ expect(toast.info).toHaveBeenCalledWith('hi', 15000);
+ // NaN must not slip through the clamp as a sticky toast.
+ fromFrame(iframe, { type: 'trek:notify', level: 'info', message: 'ho', duration: NaN });
+ expect(toast.info).toHaveBeenLastCalledWith('ho');
+ });
+
+ it('FE-PLUGINS-FRAME-013: swapping pluginId in place restarts the bridge for the new plugin', () => {
+ const { container, rerender } = render( );
+ fireEvent.load(container.querySelector('iframe')!);
+
+ rerender( );
+ const next = container.querySelector('iframe')!; // keyed by pluginId -> fresh element
+ expect(next.getAttribute('src')).toBe('/plugin-frame/beta/index.html');
+ act(() => { fireEvent.load(next); });
+
+ // Without the per-plugin reset this would be refused as a "navigated" frame.
+ fromFrame(next, { type: 'trek:navigate', to: '/dashboard' });
+ expect(navigate).toHaveBeenCalledWith('/dashboard');
+ });
});
diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx
index 207362f66..d5e6889c8 100644
--- a/client/src/components/Plugins/PluginFrame.tsx
+++ b/client/src/components/Plugins/PluginFrame.tsx
@@ -4,7 +4,9 @@ import { useTranslation } from '../../i18n'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
+import ConfirmDialog from '../shared/ConfirmDialog'
import { pluginsApi } from '../../api/client'
+import { addListener, removeListener } from '../../api/websocket'
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
@@ -81,6 +83,17 @@ interface PluginFrameProps {
tripId?: string | null
/** The place in view — set for a place-detail slot so the plugin can scope to it. */
placeId?: string | null
+ /** The day in view — set for a day-detail slot so the plugin can scope to it. */
+ dayId?: string | null
+ /** The reservation in view — set for a reservation-detail slot so the plugin can scope to it. */
+ reservationId?: string | null
+ /**
+ * Full-page hosts (trip tab, /plugins/:id) set this so the frame always fills
+ * its container. Without it a kit-based plugin auto-reports its content height
+ * (trek:resize) and the page collapses to a floating island with dead space
+ * below — right for dashboard widgets, wrong for a page.
+ */
+ fill?: boolean
className?: string
title?: string
}
@@ -89,11 +102,22 @@ type Inbound =
| { type: 'trek:ready' }
| { type: 'trek:context:request' }
| { type: 'trek:navigate'; to: string }
- | { type: 'trek:notify'; level?: 'info' | 'success' | 'warning' | 'error'; message?: string }
+ | { type: 'trek:notify'; level?: 'info' | 'success' | 'warning' | 'error'; message?: string; duration?: number }
| { type: 'trek:resize'; height?: number }
| { type: 'trek:invoke'; requestId: string; sub: string; method?: string; body?: unknown }
+ | { type: 'trek:confirm'; requestId: string; title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }
+ | { type: 'trek:openExternal'; url?: string }
-export default function PluginFrame({ pluginId, tripId = null, placeId = null, className, title }: PluginFrameProps) {
+interface ConfirmRequest {
+ requestId: string
+ title?: string
+ message?: string
+ confirmLabel?: string
+ cancelLabel?: string
+ danger: boolean
+}
+
+export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, className, title }: PluginFrameProps) {
const frameRef = useRef(null)
// A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its
// window identity keeps matching our iframe afterwards. Track loads and refuse
@@ -107,12 +131,24 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c
const { locale } = useTranslation()
const navigate = useNavigate()
const toast = useToast()
+ // useToast returns a fresh object every render; keep it out of the effect deps
+ // (via this ref) or the bridge effect would tear down and re-post the context on
+ // EVERY parent re-render instead of only when the context inputs change.
+ const toastRef = useRef(toast)
+ toastRef.current = toast
const userId = useAuthStore((s) => s.user?.id)
const userName = useAuthStore((s) => s.user?.username ?? null)
const userAvatar = useAuthStore((s) => s.user?.avatar_url ?? null)
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
const settings = useSettingsStore((s) => s.settings)
const [height, setHeight] = useState(null)
+ // A host-rendered ConfirmDialog on the plugin's behalf: sandboxed frames have no
+ // allow-modals and can't overlay the host, so destructive plugin actions get the
+ // same native confirm every TREK feature uses. One at a time; extras are refused.
+ // The ref mirrors the state so the message handler can gate + answer without
+ // side effects inside a setState updater (StrictMode runs updaters twice).
+ const [confirmReq, setConfirmReq] = useState(null)
+ const confirmReqRef = useRef(null)
// opaque frame -> targetOrigin must be '*'. Hoisted so the iframe's onLoad can
// deliver the context too: the trek:ready handshake alone is racy — if the frame
@@ -123,9 +159,14 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c
type: 'trek:context',
tripId,
placeId,
+ dayId,
+ reservationId,
userId: userId != null ? String(userId) : null,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
locale,
+ // Mirror of what TranslationProvider stamps on — an RTL host (Arabic)
+ // wants RTL plugin UIs too, and the frame can't read our DOM to find out.
+ dir: (document.documentElement.getAttribute('dir') === 'rtl' ? 'rtl' : 'ltr') as 'rtl' | 'ltr',
hostOrigin: window.location.origin,
// #4 richer context — non-secret display data so plugins render natively:
// who the user is (name/avatar/isAdmin — never email/role beyond a boolean),
@@ -143,7 +184,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
tokens: readThemeTokens(),
- }), [tripId, placeId, userId, locale, userName, userAvatar, isAdmin, settings])
+ }), [tripId, placeId, dayId, reservationId, userId, locale, userName, userAvatar, isAdmin, settings])
useEffect(() => {
const frame = frameRef.current
@@ -175,11 +216,47 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c
case 'trek:notify': {
const text = String(msg.message ?? '').slice(0, 200)
const level = msg.level ?? 'info'
- if (text) (toast[level] ?? toast.info)(text)
+ if (!text) break
+ const t = toastRef.current
+ const show = t[level] ?? t.info
+ // Optional duration, clamped so a plugin can neither flash nor park a
+ // toast (isFinite: NaN would slip through the clamp as duration 0 = sticky).
+ if (Number.isFinite(msg.duration)) show(text, Math.min(Math.max(msg.duration as number, 1500), 15000))
+ else show(text)
+ break
+ }
+ case 'trek:confirm': {
+ if (typeof msg.requestId !== 'string' || !msg.requestId) break
+ if (confirmReqRef.current) {
+ // One dialog at a time — answer the newcomer 'not confirmed' right away.
+ post({ type: 'trek:confirm:result', requestId: msg.requestId, confirmed: false })
+ break
+ }
+ const req: ConfirmRequest = {
+ requestId: msg.requestId,
+ title: typeof msg.title === 'string' ? msg.title.slice(0, 120) : undefined,
+ message: typeof msg.message === 'string' ? msg.message.slice(0, 500) : undefined,
+ confirmLabel: typeof msg.confirmLabel === 'string' ? msg.confirmLabel.slice(0, 40) : undefined,
+ cancelLabel: typeof msg.cancelLabel === 'string' ? msg.cancelLabel.slice(0, 40) : undefined,
+ danger: msg.danger !== false,
+ }
+ confirmReqRef.current = req
+ setConfirmReq(req)
+ break
+ }
+ case 'trek:openExternal': {
+ // The sandbox has no allow-popups, so the HOST opens the link — but only
+ // real web URLs, never javascript:/data:/file: or anything else.
+ try {
+ const u = new URL(String(msg.url ?? ''))
+ if (u.protocol === 'https:' || u.protocol === 'http:') {
+ window.open(u.href, '_blank', 'noopener,noreferrer')
+ }
+ } catch { /* not a URL — ignore */ }
break
}
case 'trek:resize':
- if (typeof msg.height === 'number' && msg.height > 0) setHeight(Math.min(msg.height, 2000))
+ if (!fill && typeof msg.height === 'number' && msg.height > 0) setHeight(Math.min(msg.height, 2000))
break
case 'trek:invoke': {
// The plugin's own route, called host-side with the user's session.
@@ -197,6 +274,29 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c
window.addEventListener('message', onMessage)
+ // Live locale/format sync: this effect re-runs whenever buildContext's inputs
+ // (locale, settings, trip/place) change, so a loaded frame gets the fresh
+ // context pushed instead of staying stale until reload. First delivery is
+ // handled by onLoad; navigated frames (loads > 1) are never re-bridged.
+ if (loadsRef.current === 1) post(context())
+
+ // Forward event *names* for the trip in view, mirroring the server-side events
+ // surface: only { event, tripId }, never payloads — the frame's user is already
+ // looking at this trip. Core events plus the plugin's OWN namespaced broadcasts
+ // (plugin:{id}:*) pass; other plugins' broadcasts don't. Trip events only reach
+ // the socket while a planner has the trip joined, so this is a planner-side
+ // refresh signal — dashboard widgets still poll.
+ let wsForward: ((ev: Record) => void) | null = null
+ if (tripId) {
+ wsForward = (ev) => {
+ if (loadsRef.current !== 1) return
+ if (!ev || typeof ev.type !== 'string' || ev.tripId == null || String(ev.tripId) !== tripId) return
+ if (ev.type.startsWith('plugin:') && !ev.type.startsWith(`plugin:${pluginId}:`)) return
+ post({ type: 'trek:event', event: ev.type, tripId })
+ }
+ addListener(wsForward)
+ }
+
// The frame is opaque-origin and can't read our DOM, and we otherwise send the
// context (incl. theme + tokens) only once on trek:ready — so a plugin can't
// follow an in-app appearance change. Watch the element for anything
@@ -229,23 +329,63 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c
attributeFilter: ['class', 'style', 'data-scheme', 'data-density', 'data-no-transparency', 'data-reduce-motion'],
})
- return () => { window.removeEventListener('message', onMessage); themeObserver.disconnect() }
- }, [pluginId, navigate, toast, postFrame, buildContext])
+ return () => {
+ window.removeEventListener('message', onMessage)
+ themeObserver.disconnect()
+ if (wsForward) removeListener(wsForward)
+ }
+ }, [pluginId, tripId, fill, navigate, postFrame, buildContext])
+
+ // Hosts swap pluginId in place (tab bar, /plugins/:id route) — the iframe below
+ // is keyed so the document is a fresh first load, and the per-plugin bridge
+ // state must start over with it or the new plugin would be refused as a
+ // "navigated" frame (loads > 1) and an open confirm would leak across plugins.
+ useEffect(() => {
+ loadsRef.current = 0
+ setHeight(null)
+ confirmReqRef.current = null
+ setConfirmReq(null)
+ }, [pluginId])
+
+ const answerConfirm = (confirmed: boolean) => {
+ const req = confirmReqRef.current
+ if (req) postFrame({ type: 'trek:confirm:result', requestId: req.requestId, confirmed })
+ confirmReqRef.current = null
+ setConfirmReq(null)
+ }
+
+ // The dialog title always leads with the host-controlled plugin name, so a
+ // plugin cannot dress its confirm up as a TREK system dialog.
+ const pluginLabel = title || pluginId
+ const confirmTitle = confirmReq?.title ? `${pluginLabel} — ${confirmReq.title}` : pluginLabel
return (
-
{customReminder && (
- update('reminder_days', Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
+ onValueChange={raw => update('reminder_days', Math.max(1, Math.min(30, Number(raw) || 1)))}
className="w-20 px-3 py-1.5 border border-slate-200 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-300" />
{t('trips.reminderDaysBefore')}
diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx
index ba3245b6f..f1bb06a69 100644
--- a/client/src/components/Trips/TripMembersModal.tsx
+++ b/client/src/components/Trips/TripMembersModal.tsx
@@ -268,9 +268,12 @@ interface TripMembersModalProps {
onClose: () => void
tripId: number
tripTitle: string
+ /** Called after the roster changes (guest/member added, renamed or removed) so the
+ * planner can refresh its members for Costs participants, Collab, etc. */
+ onMembersChanged?: () => void
}
-export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: TripMembersModalProps) {
+export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle, onMembersChanged }: TripMembersModalProps) {
const [data, setData] = useState(null)
const [allUsers, setAllUsers] = useState([])
const [loading, setLoading] = useState(false)
@@ -297,11 +300,14 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
}
}, [isOpen, tripId])
- const loadMembers = async () => {
+ const loadMembers = async (notify = false) => {
setLoading(true)
try {
const d = await tripsApi.getMembers(tripId)
setData(d)
+ // Notify the planner to re-sync (Costs participants etc.) only after an actual
+ // roster mutation — not on the initial open load, which would be a redundant fetch.
+ if (notify) onMembersChanged?.()
} catch {
toast.error(t('members.loadError'))
} finally {
@@ -323,7 +329,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
const target = allUsers.find(u => String(u.id) === String(selectedUserId))
await tripsApi.addMember(tripId, target.username)
setSelectedUserId('')
- await loadMembers()
+ await loadMembers(true)
toast.success(`${target.username} ${t('members.added')}`)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.addError')))
@@ -354,7 +360,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
try {
await tripsApi.createGuest(tripId, name)
setNewGuestName('')
- await loadMembers()
+ await loadMembers(true)
toast.success(t('members.guestAdded'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.guestAddError')))
@@ -369,7 +375,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
try {
await tripsApi.renameGuest(tripId, userId, name)
setRenamingGuestId(null)
- await loadMembers()
+ await loadMembers(true)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.guestRenameError')))
}
@@ -380,7 +386,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
setRemovingId(userId)
try {
await tripsApi.deleteGuest(tripId, userId)
- await loadMembers()
+ await loadMembers(true)
toast.success(t('members.guestRemoved'))
} catch {
toast.error(t('members.removeError'))
@@ -398,7 +404,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
try {
await tripsApi.removeMember(tripId, userId)
if (isSelf) { onClose(); window.location.reload() }
- else { await loadMembers(); toast.success(t('members.removed')) }
+ else { await loadMembers(true); toast.success(t('members.removed')) }
} catch {
toast.error(t('members.removeError'))
} finally {
diff --git a/client/src/components/Vacay/VacaySettings.test.tsx b/client/src/components/Vacay/VacaySettings.test.tsx
index efcc310a9..66d0b60a5 100644
--- a/client/src/components/Vacay/VacaySettings.test.tsx
+++ b/client/src/components/Vacay/VacaySettings.test.tsx
@@ -7,6 +7,7 @@ import { server } from '../../../tests/helpers/msw/server'
import { http, HttpResponse } from 'msw'
import { useVacayStore } from '../../store/vacayStore'
import VacaySettings from './VacaySettings'
+import { fetchRegionOptions } from './holidayRegions'
const basePlan = {
id: 1,
@@ -414,6 +415,40 @@ describe('VacaySettings', () => {
expect(updatePlan).toHaveBeenCalledWith({ company_holidays_enabled: true })
})
+ it('FE-COMP-VACAYSETTINGS-019: fetchRegionOptions lists the full ISO subdivision set incl. states with no holiday (regression #1456: US-WA)', async () => {
+ // US 2026 nager data tags some states but NOT Washington — it must still appear.
+ server.use(
+ http.get('/api/addons/vacay/holidays/:year/:country', () =>
+ HttpResponse.json([
+ { date: '2026-01-01', global: true, counties: null },
+ { date: '2026-04-25', global: false, counties: ['US-CA', 'US-VT', 'US-WI'] },
+ ])
+ ),
+ )
+
+ const opts = await fetchRegionOptions('US')
+
+ // Washington is present with its proper name, and options are sorted by label.
+ expect(opts).toContainEqual({ value: 'US-WA', label: 'Washington' })
+ const labels = opts.map(o => o.label)
+ expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)))
+ // Other previously-missing states surface too.
+ expect(opts.map(o => o.value)).toEqual(expect.arrayContaining(['US-AR', 'US-FL', 'US-NV', 'US-WY']))
+ })
+
+ it('FE-COMP-VACAYSETTINGS-020: fetchRegionOptions returns [] for a nationwide-only country (no region picker forced)', async () => {
+ server.use(
+ http.get('/api/addons/vacay/holidays/:year/:country', () =>
+ HttpResponse.json([
+ { date: '2026-01-01', global: true, counties: null },
+ { date: '2026-12-25', global: true, counties: null },
+ ])
+ ),
+ )
+
+ expect(await fetchRegionOptions('JP')).toEqual([])
+ })
+
it('FE-COMP-VACAYSETTINGS-018: adding weekend day calls updatePlan with day added', async () => {
const user = userEvent.setup()
const updatePlan = vi.fn().mockResolvedValue(undefined)
diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx
index 0ad1838af..f828b257c 100644
--- a/client/src/components/Vacay/VacaySettings.tsx
+++ b/client/src/components/Vacay/VacaySettings.tsx
@@ -5,6 +5,7 @@ import { getIntlLanguage, useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
+import { fetchRegionOptions } from './holidayRegions'
import type { VacayHolidayCalendar } from '../../types'
interface VacaySettingsProps {
@@ -255,30 +256,6 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
)
}
-// ── shared region-loading helper ─────────────────────────────────────────────
-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 allCounties = new Set
()
- r.data.forEach(h => { if (h.counties) h.counties.forEach(c => allCounties.add(c)) })
- if (allCounties.size === 0) return []
- return [...allCounties].sort().map(c => {
- let label = c.split('-')[1] || c
- if (c.startsWith('DE-')) {
- const m: Record = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
- label = m[c.split('-')[1]] || label
- } else if (c.startsWith('CH-')) {
- const m: Record = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
- label = m[c.split('-')[1]] || label
- }
- return { value: c, label }
- })
- } catch {
- return []
- }
-}
-
// ── Existing calendar row (inline edit) ──────────────────────────────────────
function CalendarRow({ cal, countries, onUpdate, onDelete }: {
cal: VacayHolidayCalendar
diff --git a/client/src/components/Vacay/VacayStats.test.tsx b/client/src/components/Vacay/VacayStats.test.tsx
index 84f6bf695..a923d3caa 100644
--- a/client/src/components/Vacay/VacayStats.test.tsx
+++ b/client/src/components/Vacay/VacayStats.test.tsx
@@ -95,7 +95,7 @@ describe('VacayStats', () => {
render( )
// The vacation_days tile shows "25" as a standalone div; click it to trigger edit
await user.click(screen.getByText('25'))
- expect(screen.getByRole('spinbutton')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('FE-COMP-VACAYSTATS-009: Pressing Enter in editor calls updateVacationDays', async () => {
@@ -104,7 +104,7 @@ describe('VacayStats', () => {
seedStore(useVacayStore, { stats: [buildStat({ user_id: 1 })] })
render( )
await user.click(screen.getByText('25'))
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '30')
await user.keyboard('{Enter}')
@@ -117,11 +117,11 @@ describe('VacayStats', () => {
seedStore(useVacayStore, { stats: [buildStat({ user_id: 1 })] })
render( )
await user.click(screen.getByText('25'))
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '99')
await user.keyboard('{Escape}')
- expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(mockUpdateVacationDays).not.toHaveBeenCalled()
})
@@ -146,6 +146,6 @@ describe('VacayStats', () => {
})
render( )
await user.click(screen.getByText('25'))
- expect(screen.getByRole('spinbutton')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
diff --git a/client/src/components/Vacay/VacayStats.tsx b/client/src/components/Vacay/VacayStats.tsx
index 6efac2fc1..92961fd13 100644
--- a/client/src/components/Vacay/VacayStats.tsx
+++ b/client/src/components/Vacay/VacayStats.tsx
@@ -4,6 +4,7 @@ import { useVacayStore } from '../../store/vacayStore'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import type { VacayStat, TranslationFn } from '../../types'
+import { NumericInput } from '../shared/NumericInput'
export default function VacayStats() {
@@ -101,10 +102,9 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
{t('vacay.entitlementDays')} {canEdit && !editing && }
{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/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 {
/>
- {!mapOverlay && (
+ {(!mapOverlay || canAddPlace) && (