fix: make the app keyboard-reachable, and close four DoS-able patterns

The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.

**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.

`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.

**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.

Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.

sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
This commit is contained in:
Maurice
2026-08-25 18:28:40 +02:00
committed by Maurice
parent 52d1a0720f
commit 1ad2e92142
225 changed files with 1721 additions and 735 deletions
@@ -311,6 +311,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
<div
className="bg-[rgba(0,0,0,0.4)]"
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
role="presentation"
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
@@ -318,6 +319,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
}}
>
<div
role="presentation"
onClick={e => e.stopPropagation()}
className="bg-surface-card"
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
@@ -112,6 +112,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
return createPortal(
<div
role="presentation"
className="bg-[rgba(0,0,0,0.4)]"
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onMouseDown={(e) => { mouseDownTarget.current = e.target }}
@@ -121,6 +122,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
}}
>
<div
role="presentation"
onClick={(e) => e.stopPropagation()}
className="bg-surface-card"
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
@@ -149,7 +151,8 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
onChange={handleInputChange}
/>
<div
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
@@ -173,7 +176,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
) : (
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
)}
</div>
</button>
{error && (
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'pre-wrap', marginTop: 8 }}>
@@ -144,8 +144,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
overflow: 'hidden', maxHeight: collapsed ? 'none' : '60vh', display: 'flex', flexDirection: 'column',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: collapsed ? '12px 16px 12px 20px' : '18px 16px 14px 20px', borderBottom: collapsed ? 'none' : '1px solid var(--border-faint)', cursor: 'pointer' }}
{/* Header. Clicking the bar collapses the panel, but that is a mouse
shortcut for the chevron button below, a real button with a title that
does the same thing. So the bar declares itself presentational instead
of becoming a second, unlabelled tab stop wrapped around the rename
input and the two icon buttons. */}
<div role="presentation" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: collapsed ? '12px 16px 12px 20px' : '18px 16px 14px 20px', borderBottom: collapsed ? 'none' : '1px solid var(--border-faint)', cursor: 'pointer' }}
onClick={() => toggleCollapse()}>
<div className="bg-surface-secondary" style={{ width: collapsed ? 36 : 44, height: collapsed ? 36 : 44, borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'all 0.15s ease' }}>
<Calendar size={collapsed ? 16 : 20} className="text-content" />
@@ -200,7 +204,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ overflowY: 'auto', padding: '14px 20px 18px', display: collapsed ? 'none' : 'block' }}>
{/* ── Weather ── */}
{day.date && lat && lng && (
{!!(day.date && lat && lng) && (
loading ? (
<div style={{ textAlign: 'center', padding: 16, color: 'var(--text-faint)', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
<div style={{ width: 18, height: 18, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 6px' }} />
@@ -285,7 +289,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
if (dayReservations.length === 0) return null
return (
<div style={{ marginBottom: 0 }}>
{day.date && lat && lng && <div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />}
{!!(day.date && lat && lng) && <div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />}
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>{t('day.reservations')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{dayReservations.map(r => {
@@ -400,7 +404,7 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays,
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px' }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{acc.place_image ? (
<img src={acc.place_image} style={{ width: '100%', height: '100%', borderRadius: 10, objectFit: 'cover' }} />
<img src={acc.place_image} alt="" style={{ width: '100%', height: '100%', borderRadius: 10, objectFit: 'cover' }} />
) : (
<Hotel size={16} style={{ color: 'var(--text-muted)' }} />
)}
@@ -454,12 +458,17 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays,
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.title}</div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', display: 'flex', gap: 6, marginTop: 1 }}>
<span>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
{linked.confirmation_number && <span
{/* Reveal toggle for a blurred code: a real button, so it can
be reached by keyboard. With blurring off there is nothing
to toggle, and it stays out of the tab order. */}
{linked.confirmation_number && <button
type="button"
disabled={!blurCodes}
onMouseEnter={e => { if (blurCodes) e.currentTarget.style.filter = 'none' }}
onMouseLeave={e => { if (blurCodes) e.currentTarget.style.filter = 'blur(4px)' }}
onClick={e => { if (blurCodes) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(4px)' : 'none' } }}
style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default' }}
>#{linked.confirmation_number}</span>}
style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default', background: 'none', border: 'none', padding: 0, font: 'inherit', color: 'inherit' }}
>#{linked.confirmation_number}</button>}
</div>
</div>
</div>
@@ -535,9 +544,12 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
<>
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
{showHotelPicker && createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
// Click screen and the card that stops the click from reaching it:
// neither is a control, and the popup has its own X and Cancel
// buttons, so both stay presentational.
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setShowHotelPicker(false)}>
<div onClick={e => e.stopPropagation()} style={{
<div role="presentation" onClick={e => e.stopPropagation()} style={{
width: '100%', maxWidth: 900, borderRadius: 16, overflow: 'hidden',
background: 'var(--bg-card)', boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
...font,
@@ -655,7 +667,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{p.image_url ? (
<img src={p.image_url} style={{ width: '100%', height: '100%', borderRadius: 8, objectFit: 'cover' }} />
<img src={p.image_url} alt="" style={{ width: '100%', height: '100%', borderRadius: 8, objectFit: 'cover' }} />
) : (
<MapPin size={13} style={{ color: 'var(--text-faint)' }} />
)}
@@ -1466,6 +1466,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setIncomingLegMode(assignmentId, dayId, null) },
])
}
// Enter/Space on a route connector opens the same mode menu a click opens. A key
// event carries no pointer position, so the menu is anchored to the connector.
const openLegMenuByKey = (e: React.KeyboardEvent<HTMLDivElement>, open: (m: React.MouseEvent) => void) => {
if (e.key !== 'Enter' && e.key !== ' ') return
const rect = e.currentTarget.getBoundingClientRect()
open({
clientX: rect.left,
clientY: rect.bottom,
preventDefault: () => e.preventDefault(),
stopPropagation: () => e.stopPropagation(),
} as unknown as React.MouseEvent)
}
return (
<div data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
{/* Toolbar */}
@@ -1500,6 +1514,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
{days.map((day, index) => {
const isSelected = selectedDayId === day.id
// Shared by the click and the key handler so the two can never drift
// apart — pressing Enter on the row has to toggle, not just select.
// Named for the selection, not the expand/collapse `toggleDay` above.
const toggleDaySelection = () => {
if (isSelected) { onSelectDay(null); if (onDayDetail) onDayDetail(null) }
else { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }
}
const isExpanded = expandedDays.has(day.id)
const da = getDayAssignments(day.id)
const cost = dayTotalCost(day.id, assignments, costBase, currency, locale, fxRates)
@@ -1580,7 +1601,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
className="dp-day-header"
data-selected={isSelected}
// Clicking the selected day again deselects it — same as the day panel's × (#2024).
onClick={() => { if (isSelected) { onSelectDay(null); if (onDayDetail) onDayDetail(null) } else { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) } }}
// Selecting the day has no other trigger, so the row carries the
// button role itself. The badges inside it are buttons of their
// own, hence the key handler only answers for the row — and it
// toggles exactly like the click, rather than only selecting.
role="button"
tabIndex={0}
onClick={() => toggleDaySelection()}
onKeyDown={e => {
if (e.target !== e.currentTarget) return
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
toggleDaySelection()
}}
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDragOverDayId(null) }}
onDrop={e => handleDropOnDay(e, day.id)}
@@ -1686,10 +1719,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const isCheckOut = acc.end_day_id === day.id
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)'
return (
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
<button type="button" key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', borderRadius: 7, padding: '2px 7px 2px 6px', border: 'none', font: 'inherit', textAlign: 'left' }}>
<Hotel size={11} strokeWidth={1.8} style={{ color: iconColor, flexShrink: 0 }} />
<span className="text-content-muted" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
</span>
</button>
)
})
})()}
@@ -1698,10 +1731,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const activeRentals = getActiveRentalsForDay(day.id)
if (activeRentals.length === 0) return null
return activeRentals.map(r => (
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
<button type="button" key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', borderRadius: 7, padding: '2px 7px 2px 6px', border: 'none', font: 'inherit', textAlign: 'left' }}>
<Car size={11} strokeWidth={1.8} className="text-content-faint" style={{ flexShrink: 0 }} />
<span className="text-content-muted" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
</span>
</button>
))
})()}
</div>
@@ -1721,19 +1754,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{/* Public transit search (#1065) — replaced the rename pencil,
which moved next to the day name in the day detail view. */}
{onPlanTransit ? (
<button onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} title={t('transit.title')} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
<button type="button" onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} title={t('transit.title')} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
<TramFront size={14} strokeWidth={1.8} />
</button>
) : <div style={{ borderRight: div, borderBottom: div }} />}
{onAddTransport ? (
<button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
<button type="button" onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
<Plus size={14} strokeWidth={1.8} />
</button>
) : <div style={{ borderBottom: div }} />}
<button onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
<button type="button" onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
<FileText size={14} strokeWidth={1.8} />
</button>
<button onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
<button type="button" onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
{isExpanded ? <ChevronDown size={15} strokeWidth={1.8} /> : <ChevronRight size={15} strokeWidth={1.8} />}
</button>
</div>
@@ -1816,7 +1849,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const targetId = hotelLegs[day.id]?.top?.targetId
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.top!.seg} name={hotelLegs[day.id]!.top!.name} profile={routeProfile} placement="top" />
return canEditDays && targetId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.top!.seg))} style={{ cursor: 'pointer' }}>
{connector}
</div>
) : connector
@@ -1890,11 +1923,18 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}
const moveUp = (e) => { e.stopPropagation(); arrowMove('up') }
const moveDown = (e) => { e.stopPropagation(); arrowMove('down') }
const selectPlace = () => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }
return (
<React.Fragment key={`place-${assignment.id}`}>
<div
className="dp-row"
// Picking the place out of the day has no other trigger, so
// the row is the control. Its grip, lock and arrows are
// buttons in their own right, hence the key handler only
// answers for the row itself.
role="button"
tabIndex={0}
draggable={canEditDays && !dragDisabled}
onDragStart={e => {
if (!canEditDays || dragDisabled) { e.preventDefault(); return }
@@ -1950,7 +1990,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onClick={selectPlace}
onKeyDown={e => {
if (e.target !== e.currentTarget) return
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
selectPlace()
}}
onContextMenu={e => {
// Flat entries, one per app: the menu has room to
// grow and a submenu would need a component that
@@ -1998,11 +2044,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>}
<div
<button
type="button"
aria-label={lockedIds.has(assignment.id) ? t('planner.clickToUnlock') : t('planner.keepPosition')}
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
onMouseLeave={() => setLockHoverId(null)}
style={{ position: 'relative', flexShrink: 0, cursor: 'pointer' }}
style={{ position: 'relative', flexShrink: 0, cursor: 'pointer', display: 'flex', background: 'none', border: 'none', padding: 0, font: 'inherit' }}
>
<PlaceAvatar place={place} category={cat} size={28} />
{/* Hover/locked overlay */}
@@ -2030,7 +2078,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
: t('planner.keepPosition')}
</div>
)}
</div>
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{cat && (() => {
@@ -2145,7 +2193,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
marginLeft: pi > 0 ? -4 : 0, flexShrink: 0,
overflow: 'hidden',
}}>
{p.avatar ? <img src={avatarSrc(p.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
{p.avatar ? <img src={avatarSrc(p.avatar)!} alt={p.username ?? ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
</div>
))}
{assignment.participants.length > 5 && (
@@ -2191,7 +2239,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
{routeLegs[day.id]?.[assignment.id] && (canEditDays ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, assignment.id, day.id, routeLegs[day.id]![assignment.id]))} style={{ cursor: 'pointer' }}>
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
</div>
) : (
@@ -2247,22 +2295,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const displayTime = getDisplayTimeForDay(res, day.id)
const legKey = res.__leg ? `leg${res.__leg.index}` : 'x'
const openTransportRow = () => {
const target = reservations.find(x => x.id === res.id) ?? res
// A transit journey opens its own journey view — the rich
// stop-by-stop breakdown with its booking fields, never the
// generic edit form (#1065).
if (transitMeta) {
if (onOpenTransit) onOpenTransit(target)
else setTransportDetail(target)
return
}
if (!canEditDays) return
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
else onEditReservation?.(target)
}
return (
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
<div
onClick={() => {
const target = reservations.find(x => x.id === res.id) ?? res
// A transit journey opens its own journey view — the rich
// stop-by-stop breakdown with its booking fields, never the
// generic edit form (#1065).
if (transitMeta) {
if (onOpenTransit) onOpenTransit(target)
else setTransportDetail(target)
return
}
if (!canEditDays) return
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
else onEditReservation?.(target)
// Opening the booking has no other trigger, so the row is
// the control; the buttons inside it answer for themselves.
role="button"
tabIndex={0}
onClick={openTransportRow}
onKeyDown={e => {
if (e.target !== e.currentTarget) return
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
openTransportRow()
}}
onDragOver={e => {
e.preventDefault(); e.stopPropagation()
@@ -2433,7 +2493,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const nextPlaceId = merged.slice(idx + 1).find(i => i.type === 'place' && i.data.place?.lat && i.data.place?.lng)?.data.id
const connector = <RouteConnector seg={routeLegs[day.id]![res.id]} profile={routeProfile} />
return canEditDays && nextPlaceId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id]))} style={{ cursor: 'pointer' }}>
{connector}
</div>
) : connector
@@ -2567,7 +2627,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const targetId = hotelLegs[day.id]?.bottom?.targetId
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.bottom!.seg} name={hotelLegs[day.id]!.bottom!.name} profile={routeProfile} placement="bottom" />
return canEditDays && targetId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.bottom!.seg))} style={{ cursor: 'pointer' }}>
{connector}
</div>
) : connector
@@ -69,12 +69,12 @@ function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote,
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
backdropFilter: 'blur(3px)',
}} onClick={() => cancelNote(Number(dayId))}>
}} role="presentation" onClick={() => cancelNote(Number(dayId))}>
<div className="bg-surface-card note-modal" style={{
width: '100%', maxWidth: 720, maxHeight: '90vh', overflowY: 'auto', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 16,
}} onClick={e => e.stopPropagation()}>
}} role="presentation" onClick={e => e.stopPropagation()}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
</div>
@@ -22,12 +22,12 @@ interface DayPlanSidebarTimeConfirmModalProps {
export function DayPlanSidebarTimeConfirmModal({ timeConfirm, setTimeConfirm, confirmTimeRemoval, t }: DayPlanSidebarTimeConfirmModalProps) {
if (!timeConfirm) return null
return createPortal(
<div className="bg-[rgba(0,0,0,0.3)]" style={{
<div role="presentation" className="bg-[rgba(0,0,0,0.3)]" style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(3px)',
}} onClick={() => setTimeConfirm(null)}>
<div className="bg-surface-card" style={{
<div role="presentation" className="bg-surface-card" style={{
width: 340, borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 12,
@@ -30,8 +30,14 @@ export function DayPlanSidebarTransportDetailModal({
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(3px)',
}} onClick={() => setTransportDetail(null)}>
<div className="bg-surface-card" style={{
}}
role="button" tabIndex={0} aria-label={t('common.cancel')}
onClick={() => setTransportDetail(null)}
onKeyDown={e => {
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setTransportDetail(null) }
}}>
<div className="bg-surface-card" role="presentation" style={{
width: 380, maxHeight: '80vh', overflowY: 'auto',
borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
@@ -109,21 +115,29 @@ export function DayPlanSidebarTransportDetailModal({
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{detailFields.map((f, i) => {
const shouldBlur = f.sensitive && useSettingsStore.getState().settings.blur_booking_codes
const valueStyle = {
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, wordBreak: 'break-word',
filter: shouldBlur ? 'blur(5px)' : 'none', transition: 'filter 0.2s',
cursor: shouldBlur ? 'pointer' : 'default',
userSelect: shouldBlur ? 'none' : 'auto',
} as const
return (
<div key={i} className="bg-surface-tertiary" style={{ padding: '8px 10px', borderRadius: 8 }}>
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{f.label}</div>
<div
onMouseEnter={e => { if (shouldBlur) e.currentTarget.style.filter = 'none' }}
onMouseLeave={e => { if (shouldBlur) e.currentTarget.style.filter = 'blur(5px)' }}
onClick={e => { if (shouldBlur) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(5px)' : 'none' } }}
className="text-content"
style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, wordBreak: 'break-word',
filter: shouldBlur ? 'blur(5px)' : 'none', transition: 'filter 0.2s',
cursor: shouldBlur ? 'pointer' : 'default',
userSelect: shouldBlur ? 'none' : 'auto',
}}
>{f.value}</div>
{/* A hidden booking code is a reveal toggle, so it has to be a real
button; a plain value stays inert text and out of the tab order. */}
{shouldBlur ? (
<button
type="button"
onMouseEnter={e => { e.currentTarget.style.filter = 'none' }}
onMouseLeave={e => { e.currentTarget.style.filter = 'blur(5px)' }}
onClick={e => { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(5px)' : 'none' }}
className="text-content"
style={{ ...valueStyle, display: 'block', width: '100%', textAlign: 'left', background: 'none', border: 'none', padding: 0, fontFamily: 'inherit' }}
>{f.value}</button>
) : (
<div className="text-content" style={valueStyle}>{f.value}</div>
)}
</div>
)
})}
@@ -215,12 +229,13 @@ export function DayPlanSidebarTransportDetailModal({
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6 }}>{t('files.title')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{resFiles.map(f => (
<div key={f.id}
<button key={f.id}
type="button"
onClick={() => { setTransportDetail(null); onNavigateToFiles?.() }}
className="bg-surface-tertiary"
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
borderRadius: 8, cursor: 'pointer',
borderRadius: 8, cursor: 'pointer', border: 'none', textAlign: 'left', fontFamily: 'inherit',
transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
@@ -231,7 +246,7 @@ export function DayPlanSidebarTransportDetailModal({
{f.original_name}
</span>
<ExternalLink size={11} className="text-content-faint" style={{ flexShrink: 0 }} />
</div>
</button>
))}
</div>
</div>
@@ -123,6 +123,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
{label(day, index)}
</span>
<button
type="button"
onClick={() => move(index, index - 1)}
disabled={index === 0}
aria-label={t('dayplan.moveUp')}
@@ -131,6 +132,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
<ArrowUp size={14} strokeWidth={2} />
</button>
<button
type="button"
onClick={() => move(index, index + 1)}
disabled={index === ordered.length - 1}
aria-label={t('dayplan.moveDown')}
@@ -217,11 +217,13 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
return createPortal(
<div
role="presentation"
onClick={handleClose}
className="bg-[rgba(0,0,0,0.4)]"
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
>
<div
role="presentation"
onClick={e => e.stopPropagation()}
className="bg-surface-card"
style={{ borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)" }}
@@ -242,7 +244,8 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
onChange={handleInputChange}
/>
<div
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
@@ -277,7 +280,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
) : (
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('places.importFileDropHere')}</span>
)}
</div>
</button>
{isGpx && (
<div style={{ marginBottom: 12 }}>
@@ -285,7 +288,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
{t('places.gpxImportTypes')}
</div>
{(['waypoints', 'routes', 'tracks'] as const).map(key => (
<label key={key} onClick={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
<button type="button" key={key} role="checkbox" aria-checked={gpxOpts[key]} onClick={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer', width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}>
<div className={gpxOpts[key] ? 'bg-accent' : 'bg-transparent'} style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: gpxOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
@@ -296,7 +299,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', userSelect: 'none' }}>
{t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
</span>
</label>
</button>
))}
{gpxNoneSelected && (
<div className="text-[#b45309]" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 4 }}>{t('places.gpxImportNoneSelected')}</div>
@@ -310,7 +313,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
{t('places.kmlImportTypes')}
</div>
{(['points', 'paths'] as const).map(key => (
<label key={key} onClick={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
<button type="button" key={key} role="checkbox" aria-checked={kmlOpts[key]} onClick={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer', width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}>
<div className={kmlOpts[key] ? 'bg-accent' : 'bg-transparent'} style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: kmlOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
@@ -321,7 +324,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', userSelect: 'none' }}>
{t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
</span>
</label>
</button>
))}
{kmlNoneSelected && (
<div className="text-[#b45309]" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 4 }}>{t('places.kmlImportNoneSelected')}</div>
@@ -63,6 +63,7 @@ export function IcsSubscribeModal({ endpoint, title, description, onClose }: Ics
return createPortal(
<div
role="presentation"
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)',
@@ -838,7 +838,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
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*)$/)
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
if (match) {
e.preventDefault()
handleChange('lat', match[1])
@@ -913,7 +913,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
context (itinerary edit, or a single-assignment pool edit). Hidden when
creating, and for unassigned / multi-day pool edits where a single time
is ambiguous and wouldn't persist. */}
{place && assignmentId && (
{!!(place && assignmentId) && (
<TimeSection
form={form}
handleChange={handleChange}
@@ -1291,7 +1291,7 @@ describe('PlaceInspector', () => {
it('FE-PLANNER-INSPECTOR-093: participant chips and the add menu reset their hover styling', () => {
render(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }])} />);
const chip = screen.getByText('ada').closest('div') as HTMLElement;
const chip = screen.getByText('ada').closest('button') as HTMLElement;
fireEvent.mouseEnter(chip);
expect(chip.className).toContain('text-[#ef4444]');
fireEvent.mouseLeave(chip);
@@ -484,7 +484,7 @@ export default function PlaceInspector({
<StatusBadge status={collectionStatus} onChange={onSetStatus} t={t} />
)}
{/* Trip mode — day assignment */}
{mode === 'trip' && selectedDayId && (
{mode === 'trip' && !!selectedDayId && (
assignmentInDay ? (
<ActionButton onClick={() => onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
label={<span className="hidden sm:inline">{t('inspector.removeFromDay')}</span>} />
@@ -649,7 +649,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
const isHovered = hoveredId === member.id
const canRemove = activeMembers.length > 1
return (
<div key={member.id}
<button type="button" key={member.id} disabled={!canRemove}
onMouseEnter={() => setHoveredId(member.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() => { if (canRemove) handleRemove(member.id) }}
@@ -661,15 +661,15 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
cursor: canRemove ? 'pointer' : 'default',
transition: 'all 0.15s',
}}>
<div className="bg-surface-tertiary text-content-muted" style={{
<span className="bg-surface-tertiary text-content-muted" style={{
width: 16, height: 16, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700,
overflow: 'hidden', flexShrink: 0,
}}>
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || avatarSrc(member.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
</div>
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || avatarSrc(member.avatar)!} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
</span>
<span style={{ textDecoration: isHovered && canRemove ? 'line-through' : 'none' }}>{member.username}</span>
</div>
</button>
)
})}
@@ -706,7 +706,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 700,
overflow: 'hidden', flexShrink: 0,
}}>
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || avatarSrc(member.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || avatarSrc(member.avatar)!} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
</div>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>{member.username}</span>
{member.is_guest && <GuestBadge size="xs" />}
@@ -28,6 +28,7 @@ export function PlacesBulkCategoryModal({ count, categories, onPick, onClose }:
const { t } = useTranslation()
return createPortal(
<div
role="presentation"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
>
@@ -53,7 +54,7 @@ export function PlacesBulkCategoryModal({ count, categories, onPick, onClose }:
</button>
)
})}
<button onClick={() => onPick(null)} className="text-content-muted bg-transparent" style={{ ...rowStyle, borderTop: categories.length > 0 ? '1px solid var(--border-faint)' : 'none', marginTop: categories.length > 0 ? 2 : 0 }} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
<button type="button" onClick={() => onPick(null)} className="text-content-muted bg-transparent" style={{ ...rowStyle, borderTop: categories.length > 0 ? '1px solid var(--border-faint)' : 'none', marginTop: categories.length > 0 ? 2 : 0 }} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
<MapPin size={14} strokeWidth={2} color="var(--text-faint)" />
<span style={{ flex: 1 }}>{t('places.noCategory')}</span>
</button>
@@ -10,11 +10,13 @@ export function ListImportModal(S: SidebarState) {
} = S
return createPortal(
<div
role="presentation"
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
className="bg-[rgba(0,0,0,0.4)]"
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
>
<div
role="presentation"
onClick={e => e.stopPropagation()}
className="bg-surface-card"
style={{ borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}
@@ -9,10 +9,12 @@ export function MobileDayPickerSheet(S: SidebarState) {
} = S
return createPortal(
<div
role="presentation"
onClick={() => { setDayPickerPlace(null); setMobileShowDays(false) }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}
>
<div
role="presentation"
onClick={e => e.stopPropagation()}
className="bg-surface-card"
style={{ borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '70vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'var(--bottom-nav-h)' }}
@@ -35,10 +35,22 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
const hasGeometry = Boolean(place.route_geometry)
// Touch is reached through a long press instead of being locked out (#1616).
const dragDisabled = isMobile
// One place for what a row does, so the keyboard path below cannot drift from the click.
const activate = () => {
if (selectMode) {
toggleSelected(place.id)
} else if (isMobile) {
setDayPickerPlace(place)
} else {
onPlaceClick(isSelected ? null : place.id)
}
}
return (
<div
key={place.id}
ref={element => registerPlaceRow(place.id, element)}
role="option"
tabIndex={0}
aria-selected={isSelected}
data-place-id={place.id}
draggable={!selectMode && !dragDisabled}
@@ -48,14 +60,11 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(place.id) }
}}
onClick={() => {
if (selectMode) {
toggleSelected(place.id)
} else if (isMobile) {
setDayPickerPlace(place)
} else {
onPlaceClick(isSelected ? null : place.id)
}
onClick={activate}
onKeyDown={e => {
// Only when the row itself has focus — the "+" button inside it keeps its own key handling.
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate() }
}}
onContextMenu={selectMode ? undefined : e => onContextMenu(e, place)}
style={{
@@ -120,7 +129,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{!selectMode && !inDay && selectedDayId && (
{!selectMode && !inDay && selectedDayId !== null && (
<button type="button"
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
className="bg-surface-hover text-content-faint"
@@ -677,7 +677,8 @@ describe('ReservationModal', () => {
// Click the X to unlink
const fileRow = screen.getByText('receipt.pdf').closest('div')!;
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
// Two buttons per row: [0] opens the file, [1] is the unlink X.
const unlinkBtn = fileRow.querySelectorAll('button[type="button"]')[1];
await userEvent.click(unlinkBtn);
// File removed from attached list and "Link existing file" button reappears
@@ -1317,7 +1318,7 @@ describe('ReservationModal', () => {
render(<ReservationModal {...defaultProps} reservation={res} files={[attached]} />);
const row = screen.getByText('voucher.pdf').closest('div') as HTMLElement;
await userEvent.click(within(row).getByRole('button'));
await userEvent.click(within(row).getAllByRole('button')[1]);
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined));
delete window.__addToast;
@@ -1404,14 +1405,14 @@ describe('ReservationModal', () => {
});
});
it('FE-PLANNER-RESMODAL-086: the open-file link on an attached file does not throw', async () => {
it('FE-PLANNER-RESMODAL-086: the open-file button on an attached file does not throw', async () => {
const res = buildReservation({ id: 16, type: 'other', title: 'Trip' });
const attached = buildTripFile({ id: 80, original_name: 'ticket.pdf' });
(attached as unknown as { reservation_id: number }).reservation_id = 16;
render(<ReservationModal {...defaultProps} reservation={res} files={[attached]} />);
const row = screen.getByText('ticket.pdf').closest('div') as HTMLElement;
await userEvent.click(within(row).getByRole('link'));
await userEvent.click(within(row).getAllByRole('button')[0]);
// The download is best-effort; the row must survive a failed fetch.
expect(screen.getByText('ticket.pdf')).toBeInTheDocument();
});
@@ -698,7 +698,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<div key={f.id} className="bg-surface-secondary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8 }}>
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
<span className="text-content-secondary" style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} className="text-content-faint" style={{ display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={() => { openFile(f.url).catch(() => {}) }} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 0, flexShrink: 0 }}><ExternalLink size={11} /></button>
<button type="button" onClick={async () => {
if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch { toast.error(t('reservations.toast.updateError')) }
@@ -77,6 +77,35 @@ function buildAssignmentLookup(days, assignments) {
const fieldLabelClass = 'text-[10px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[5px]'
const fieldValueClass = 'text-[13px] font-medium text-content px-[10px] py-[8px] bg-surface-tertiary rounded-[10px]'
// A booking code is only a control while the blur setting is on: then it lifts on
// hover and toggles on click or Enter. With blurring off it is plain text, so it
// stays a plain element instead of a button that can never do anything.
function ConfirmationCode({ code, blurred, revealed, onReveal }: {
code: React.ReactNode
blurred: boolean
revealed: boolean
onReveal: (next: boolean | ((v: boolean) => boolean)) => void
}) {
const codeStyle: CSSProperties = {
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
filter: blurred && !revealed ? 'blur(5px)' : 'none',
transition: 'filter 0.2s',
}
if (!blurred) return <div className={`${fieldValueClass} text-center`} style={{ ...codeStyle, cursor: 'default' }}>{code}</div>
return (
<button
type="button"
className={`${fieldValueClass} text-center`}
style={{ ...codeStyle, cursor: 'pointer', width: '100%' }}
onMouseEnter={() => onReveal(true)}
onMouseLeave={() => onReveal(false)}
onClick={() => onReveal(v => !v)}
>
{code}
</button>
)
}
interface ReservationCardProps {
r: Reservation
tripId: number
@@ -298,20 +327,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{hasCode && (
<div>
<div className={fieldLabelClass}>{t('reservations.confirmationCode')}</div>
<div
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
onClick={() => blurCodes && setCodeRevealed(v => !v)}
className={`${fieldValueClass} text-center`}
style={{
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
cursor: blurCodes ? 'pointer' : 'default',
transition: 'filter 0.2s',
}}
>
{r.confirmation_number}
</div>
<ConfirmationCode code={r.confirmation_number} blurred={!!blurCodes} revealed={codeRevealed} onReveal={setCodeRevealed} />
</div>
)}
@@ -350,20 +366,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{coded.map((l, i) => (
<div key={i}>
<div className={fieldLabelClass}>{[l.from, l.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')}</div>
<div
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
onClick={() => blurCodes && setCodeRevealed(v => !v)}
className={`${fieldValueClass} text-center`}
style={{
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
cursor: blurCodes ? 'pointer' : 'default',
transition: 'filter 0.2s',
}}
>
{l.confirmation_number}
</div>
<ConfirmationCode code={l.confirmation_number} blurred={!!blurCodes} revealed={codeRevealed} onReveal={setCodeRevealed} />
</div>
))}
</div>
@@ -463,10 +466,10 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div className={fieldLabelClass}>{t('files.title')}</div>
<div className={fieldValueClass} style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '6px 10px' }}>
{attachedFiles.map(f => (
<a key={f.id} href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 5, textDecoration: 'none', cursor: 'pointer' }}>
<button key={f.id} type="button" onClick={() => { openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer', background: 'none', border: 'none', padding: 0, textAlign: 'left', fontFamily: 'inherit' }}>
<FileText size={11} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</a>
</button>
))}
</div>
</div>
@@ -497,12 +500,12 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Delete confirmation */}
{showDeleteConfirm && createPortal(
<div className="bg-[rgba(0,0,0,0.3)]" style={{
<div className="bg-[rgba(0,0,0,0.3)]" role="presentation" style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(3px)',
}} onClick={() => setShowDeleteConfirm(false)}>
<div className="bg-surface-card" style={{
<div className="bg-surface-card" role="presentation" style={{
width: 340, borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 12,
@@ -610,6 +613,9 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
<div
className="bg-surface-card"
onClick={() => onOpen(r)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen(r) } }}
style={{ borderRadius: 12, border: '1px solid rgba(124,58,237,0.22)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 9, cursor: 'pointer', transition: 'box-shadow 0.15s ease' }}
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
@@ -658,7 +664,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
</div>
)}
{r.travelers && r.travelers.length > 0 && (
<div style={{ paddingLeft: 44 }} onClick={e => e.stopPropagation()}>
<div style={{ paddingLeft: 44 }} role="presentation" onClick={e => e.stopPropagation()}>
<TravelerAvatarRow travelers={r.travelers} />
</div>
)}
@@ -666,7 +672,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
{/* Reservation-detail plugin slots: sandboxed, scoped to this journey. The
card itself is clickable, so keep frame interactions from opening it. */}
{detailPlugins.length > 0 && (
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div role="presentation" onClick={e => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{detailPlugins.map(p => (
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} surface="detail-slot" />
@@ -675,8 +681,8 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
</div>
)}
{confirmOpen && createPortal(
<div className="bg-[rgba(0,0,0,0.35)]" style={{ position: 'fixed', inset: 0, zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => { e.stopPropagation(); setConfirmOpen(false) }}>
<div className="bg-surface-card" style={{ borderRadius: 14, padding: 20, width: 340, boxShadow: '0 16px 48px rgba(0,0,0,0.22)' }} onClick={e => e.stopPropagation()}>
<div className="bg-[rgba(0,0,0,0.35)]" role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => { e.stopPropagation(); setConfirmOpen(false) }}>
<div className="bg-surface-card" role="presentation" style={{ borderRadius: 14, padding: 20, width: 340, boxShadow: '0 16px 48px rgba(0,0,0,0.22)' }} onClick={e => e.stopPropagation()}>
<div className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))', marginBottom: 6 }}>{t('reservations.confirm.deleteTitle')}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', marginBottom: 14 }}>{t('reservations.confirm.deleteBody', { name: r.title })}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
@@ -957,13 +963,13 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{t(addManualKey)}
</button>
{onImport && bookingImportAvailable && (
<button onClick={onImport} className="bg-surface-card text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
<button type="button" onClick={onImport} className="bg-surface-card text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
<Download size={14} strokeWidth={2} />
{t('reservations.import.cta')}
</button>
)}
{onAirTrailImport && airTrailAvailable && (
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
<button type="button" onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
<Plane size={14} strokeWidth={2} />
{t('reservations.airtrail.cta')}
</button>
@@ -539,9 +539,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
)}
</div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3, width: isMobile ? '100%' : undefined }}>
<button onClick={() => setPref('best')} style={{ ...segBtn(pref === 'best'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.best')}</button>
<button onClick={() => setPref('transfers')} style={{ ...segBtn(pref === 'transfers'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.transfers')}</button>
<button onClick={() => setPref('walking')} style={{ ...segBtn(pref === 'walking'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.walking')}</button>
<button type="button" onClick={() => setPref('best')} style={{ ...segBtn(pref === 'best'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.best')}</button>
<button type="button" onClick={() => setPref('transfers')} style={{ ...segBtn(pref === 'transfers'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.transfers')}</button>
<button type="button" onClick={() => setPref('walking')} style={{ ...segBtn(pref === 'walking'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.walking')}</button>
</div>
</div>
@@ -307,7 +307,8 @@ describe('TransportModal', () => {
);
const fileRow = screen.getByText('rental-agreement.pdf').closest('div')!;
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
// The row carries two buttons: [0] opens the file, [1] unlinks it.
const unlinkBtn = fileRow.querySelectorAll('button[type="button"]')[1];
await userEvent.click(unlinkBtn);
await waitFor(() => {
@@ -1080,7 +1081,7 @@ describe('TransportModal', () => {
render(<TransportModal {...defaultProps} reservation={res} files={[attached]} />);
const row = screen.getByText('boarding.pdf').closest('div') as HTMLElement;
await userEvent.click(within(row).getByRole('button'));
await userEvent.click(within(row).getAllByRole('button')[1]);
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined));
delete window.__addToast;
@@ -1186,7 +1187,7 @@ describe('TransportModal', () => {
render(<TransportModal {...defaultProps} reservation={res} files={[attached]} />);
const row = screen.getByText('boarding.pdf').closest('div') as HTMLElement;
await userEvent.click(within(row).getByRole('link'));
await userEvent.click(within(row).getByRole('button', { name: /open/i }));
expect(screen.getByText('boarding.pdf')).toBeInTheDocument();
});
@@ -55,11 +55,19 @@ function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', se
}
}
// "Paris Charles de Gaulle (CDG)" → "Paris Charles de Gaulle". Done as a trim
// plus an anchored test instead of /\s*\([A-Z]{3}\)\s*$/, because the leading
// \s* backtracks over every space in a long name for a quadratic worst case.
function stripAirportCode(name: string): string {
const trimmed = name.trimEnd()
return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name
}
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
if (!e || !e.code) return null
return {
iata: e.code, icao: null,
name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''),
name: e.name, city: stripAirportCode(e.name),
country: '',
lat: e.lat, lng: e.lng,
tz: e.timezone || '',
@@ -1011,7 +1019,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<div key={f.id} className="bg-surface-secondary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8 }}>
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
<span className="text-content-secondary" style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} className="text-content-faint" style={{ display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={() => { openFile(f.url).catch(() => {}) }} aria-label={t('common.open')} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 0, display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></button>
<button type="button" onClick={async () => {
if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch { toast.error(t('reservations.toast.updateError')) }
@@ -15,10 +15,16 @@ export function convertHoursLine(line: string, timeFormat: string): string {
const hasAmPm = /\d{1,2}:\d{2}\s*(AM|PM)/i.test(line)
if (timeFormat === '12h' && !hasAmPm) {
// 24h → 12h: "10:00" → "10:00 AM", "21:00" → "9:00 PM", "Uhr" entfernen
return line.replace(/\s*Uhr/g, '').replace(/(\d{1,2}):(\d{2})/g, (match, h, m) => {
// 24h → 12h: "10:00" → "10:00 AM", "21:00" → "9:00 PM", "Uhr" entfernen.
// split/trimEnd rather than /\s*Uhr/g: that pattern walks the rest of the line
// from every space it passes, so a line with no "Uhr" in it costs a pass per
// space. Cutting on the word and trimming the piece in front of it is the
// same edit, once.
const parts = line.split('Uhr')
const withoutUhr = parts.map((part, i) => (i < parts.length - 1 ? part.trimEnd() : part)).join('')
return withoutUhr.replace(/(\d{1,2}):(\d{2})/g, (match, h, m) => {
const hour = Number.parseInt(h)
if (isNaN(hour)) return match
if (Number.isNaN(hour)) return match
const period = hour >= 12 ? 'PM' : 'AM'
const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
return `${h12}:${m} ${period}`