feat(client): show Dawarich stays, routes and marks where they belong

The trip planner and the journal get a "From Dawarich" list: the stays
recorded for those dates, each one a row that can be dismissed or turned
into a place or an entry after a review step that shows time, name, note
and day. A place that came from a recording carries the Dawarich mark
next to its name so its origin stays visible afterwards.

The recorded route lies over the trip and journal maps as an overlay,
coloured per day and cut along the reader's own timezone rather than
UTC. It is fetched for the window on screen and never stored.

The Atlas gets its own panel beside the map for the countries and cities
the recordings prove, and the wish list is ticked off there, on the day
of the visit, undoably. The phone has its own take on all of it, in the
mobile shell's colours and with native sheets.
This commit is contained in:
Maurice
2026-09-12 23:35:43 +02:00
parent 5678eed55c
commit 5b4a7e82ff
108 changed files with 8945 additions and 295 deletions
@@ -512,13 +512,16 @@ describe('DayPlanSidebar', () => {
const onToggleConnection = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [res as any], onOpenTransit: vi.fn(), onToggleConnection, visibleConnectionIds: [] })} />)
// No map-connections toggle on transit rows — the expander replaces it.
expect(screen.queryByTitle(/connections/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/connections/i)).not.toBeInTheDocument()
// Collapsed: no stop names beyond the chips.
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
await user.click(screen.getByLabelText('Expand'))
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
await user.click(screen.getByLabelText('Collapse'))
// The day's own chevron carries the same accessible name, so pick the one
// that is not in the day's action block.
const rowCollapse = screen.getAllByLabelText('Collapse').find(el => !el.closest('.dp-day-actions'))!
await user.click(rowCollapse)
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
})
@@ -2159,7 +2162,7 @@ describe('DayPlanSidebar', () => {
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
const pencil = screen.getByTitle(/edit/i)
const pencil = screen.getByLabelText(/edit/i)
await user.click(pencil)
expect(onEditReservation).toHaveBeenCalledWith(res)
expect(onEditTransport).not.toHaveBeenCalled()
@@ -2177,7 +2180,7 @@ describe('DayPlanSidebar', () => {
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
const pencil = screen.getByTitle(/edit/i)
const pencil = screen.getByLabelText(/edit/i)
await user.click(pencil)
expect(onEditTransport).toHaveBeenCalledWith(res)
expect(onEditReservation).not.toHaveBeenCalled()
@@ -3435,7 +3438,7 @@ describe('DayPlanSidebar', () => {
const { rerender } = render(<DayPlanSidebar {...makeDefaultProps({
days: [day], reservations: [flight], onToggleConnection,
})} />)
const show = screen.getByTitle('Show booking routes')
const show = screen.getByLabelText('Show booking routes')
await user.click(show)
expect(onToggleConnection).toHaveBeenCalledWith(510)
fireEvent.mouseEnter(show)
@@ -3444,7 +3447,7 @@ describe('DayPlanSidebar', () => {
rerender(<DayPlanSidebar {...makeDefaultProps({
days: [day], reservations: [flight], onToggleConnection, visibleConnectionIds: [510],
})} />)
expect(screen.getByTitle('Hide booking routes')).toBeInTheDocument()
expect(screen.getByLabelText('Hide booking routes')).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-157: a non-transport booking row opens the reservation editor, unless read-only', async () => {
@@ -3560,9 +3563,9 @@ describe('DayPlanSidebar', () => {
})} />)
expect(screen.getByText('08:30 – 10:05')).toBeInTheDocument()
expect(screen.getByText('Air France AF1235')).toBeInTheDocument()
await user.click(screen.getByTitle('Show booking routes'))
await user.click(screen.getByLabelText('Show booking routes'))
expect(onToggleConnection).toHaveBeenCalledWith(520)
const edit = screen.getByTitle('Edit')
const edit = screen.getByLabelText('Edit')
fireEvent.mouseEnter(edit)
fireEvent.mouseLeave(edit)
await user.click(edit)
@@ -3608,7 +3611,7 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText(/Reservation confirmed/)).toBeInTheDocument()
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
// Earliest first, so the first pencil belongs to the parking pass.
await user.click(screen.getAllByTitle('Edit')[0])
await user.click(screen.getAllByLabelText('Edit')[0])
expect(onEditReservation).toHaveBeenCalledWith(parking)
})
@@ -3636,12 +3639,12 @@ describe('DayPlanSidebar', () => {
days: [day], places: [place], assignments: { '10': [a] }, onAddBookingToAssignment,
})} />)
const row = dragRow(screen.getByText('Hover place'))
expect(screen.queryByTitle('Add booking')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
fireEvent.mouseEnter(row)
await user.click(screen.getByTitle('Add booking'))
await user.click(screen.getByLabelText('Add booking'))
expect(onAddBookingToAssignment).toHaveBeenCalledWith(10, 11)
fireEvent.mouseLeave(row)
expect(screen.queryByTitle('Add booking')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-166: the lock tooltip switches once a stop is pinned, and unlocking clears it', async () => {
@@ -3797,7 +3800,7 @@ describe('DayPlanSidebar', () => {
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
const connector = await screen.findByTitle('Change travel mode')
const connector = await screen.findByLabelText('Change travel mode')
await user.click(connector)
await user.click(contextMenu().getByRole('button', { name: 'Walking' }))
expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenCalledWith(1, 11, 'walking')
@@ -3805,7 +3808,7 @@ describe('DayPlanSidebar', () => {
'10': [expect.objectContaining({ id: 11, leg_transport_mode: 'walking' }), expect.objectContaining({ id: 12 })],
})
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Use day default' }))
expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenLastCalledWith(1, 11, null)
})
@@ -3821,7 +3824,7 @@ describe('DayPlanSidebar', () => {
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true, onPlanTransitLeg })} />)
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
// Origin/destination + this stop's departure time, resolved back from the leg coords.
expect(onPlanTransitLeg).toHaveBeenCalledWith({
@@ -3842,7 +3845,7 @@ describe('DayPlanSidebar', () => {
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
expect(contextMenu().queryByRole('button', { name: 'Public transit' })).not.toBeInTheDocument()
})
@@ -3866,7 +3869,7 @@ describe('DayPlanSidebar', () => {
}
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11, routeShown: true, onPlanTransitLeg })} />)
// The morning bookend (hotel -> first stop) is the first connector in the list.
const connectors = await screen.findAllByTitle('Change travel mode')
const connectors = await screen.findAllByLabelText('Change travel mode')
await user.click(connectors[0])
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({
@@ -3898,7 +3901,7 @@ describe('DayPlanSidebar', () => {
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, selectedDayId: 11, routeShown: true, onPlanTransitLeg })} />)
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
// Day 11's own 16:30, not day 10's 09:00 (a trip-wide coord index would leak it).
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({ dayId: 11, time: '16:30' }))
@@ -3918,7 +3921,7 @@ describe('DayPlanSidebar', () => {
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Driving' }))
await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('nope'))
expect(refreshDays).toHaveBeenCalledWith(1)
@@ -3942,7 +3945,7 @@ describe('DayPlanSidebar', () => {
})} />)
await user.click(screen.getByRole('button', { name: 'EV eco' }))
expect(onSetRouteProfile).toHaveBeenCalledWith('plugin:ev/eco')
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(await screen.findByLabelText('Change travel mode'))
expect(contextMenu().getByRole('button', { name: 'EV eco' })).toBeInTheDocument()
})
@@ -4025,7 +4028,7 @@ describe('DayPlanSidebar', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const onAddTransport = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onAddTransport })} />)
await user.click(screen.getByTitle('Add transport'))
await user.click(screen.getByLabelText('Add transport'))
expect(onAddTransport).toHaveBeenCalledWith(10)
})
@@ -4244,7 +4247,7 @@ describe('DayPlanSidebar', () => {
fireEvent.mouseEnter(expand)
fireEvent.mouseLeave(expand)
await user.click(expand)
expect(screen.getByLabelText('Collapse')).toBeInTheDocument()
expect(screen.getAllByLabelText('Collapse').some(el => !el.closest('.dp-day-actions'))).toBe(true)
await user.click(cardRow(screen.getByText('U2 to Zoo')))
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
})
@@ -4490,10 +4493,13 @@ describe('the day route-tools row', () => {
it('keeps the optimize action reachable by name without printing it', () => {
render(<DayPlanSidebar {...makeDefaultProps(dayWithTwoStops())} />)
const btn = screen.getByRole('button', { name: /optimize/i })
// The assertion that pins the change: named, but no visible label.
// The assertion that pins the change: named, but no visible label. The name
// is `aria-label` rather than `title` — the hover text is TREK's own tooltip
// now, and a browser tooltip is not an accessible name anyway.
expect(btn).toBeInTheDocument()
expect(btn.textContent?.trim()).toBe('')
expect(btn.getAttribute('title')).toBeTruthy()
expect(btn.getAttribute('aria-label')).toBeTruthy()
expect(btn.getAttribute('title')).toBeNull()
})
/* The click itself is already pinned by FE-PLANNER-DAYPLAN-038 above, which
+199 -152
View File
@@ -29,6 +29,7 @@ import { usePluginStore } from '../../store/pluginStore'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { placeToSaveTarget } from '../Collections/saveTarget'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
@@ -1189,8 +1190,34 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}
}
/**
* Below this the plan sidebar has no room for a word on the route button.
*
* The word goes early on purpose: it shares its row with two export buttons and
* an optimise button, and a "Route" squeezed to four letters and an ellipsis is
* worse than the icon that means the same thing. The icon keeps the accessible
* name, so nothing is lost but the letters.
*/
const NARROW_PLAN_PX = 320
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
const S = useDayPlanSidebar(props)
// The plan sidebar is resizable. Below this the route button's own word is the
// first thing that stops fitting beside the two export buttons — the icon says
// the same thing, and the accessible name still spells it out.
//
// A callback ref rather than `useRef`, so the measurement happens when the
// element exists rather than on a mount that may render nothing yet.
const [panel, setPanel] = useState<HTMLElement | null>(null)
const [narrowPanel, setNarrowPanel] = useState(false)
useEffect(() => {
if (!panel || typeof ResizeObserver === 'undefined') return
const measure = (): void => setNarrowPanel(panel.clientWidth < NARROW_PLAN_PX)
measure()
const ro = new ResizeObserver(measure)
ro.observe(panel)
return () => ro.disconnect()
}, [panel])
// A stable key for the current selection. A multi-day place renders one row per
// day (same place_id, different assignment ids); selecting it by place_id alone
// (e.g. clicking an accommodation) marks every one of those rows selected, so a
@@ -1504,7 +1531,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}
return (
<div data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
<div ref={setPanel} data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
{/* Toolbar */}
<DayPlanSidebarToolbar
tripId={tripId}
@@ -1790,19 +1817,23 @@ 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 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>
<Tooltip label={t('transit.title')} placement="top">
<button type="button" onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
<TramFront size={14} strokeWidth={1.8} />
</button>
</Tooltip>
) : <div style={{ borderRight: div, borderBottom: div }} />}
{onAddTransport ? (
<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>
<Tooltip label={t('transport.addTransport')} placement="top">
<button type="button" onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} style={{ ...cell, border: 'none', borderBottom: div }} aria-label={t('transport.addTransport')}>
<Plus size={14} strokeWidth={1.8} />
</button>
</Tooltip>
) : <div style={{ borderBottom: 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 type="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)} style={{ ...cell, border: 'none' }} aria-label={isExpanded ? t('common.collapse') : t('common.expand')}>
{isExpanded ? <ChevronDown size={15} strokeWidth={1.8} /> : <ChevronRight size={15} strokeWidth={1.8} />}
</button>
</div>
@@ -1885,9 +1916,11 @@ 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)} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.top!.seg))} style={{ cursor: 'pointer' }}>
{connector}
</div>
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
<div role="button" tabIndex={0} 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' }} aria-label={t('dayplan.transportMode.change')}>
{connector}
</div>
</Tooltip>
) : connector
})()}
{daySchedule.byPosition[day.id]?.start.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
@@ -2221,34 +2254,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
{hasEndpoints && (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={11} />
</button>
)}
{canEditDays && (() => {
const isTransport = TRANSPORT_TYPES.has(res.type)
const handler = isTransport ? onEditTransport : onEditReservation
if (!handler) return null
return (
<button
<Tooltip label={t(active ? 'map.hideConnections' : 'map.showConnections')} placement="top">
<button aria-label={t(active ? 'map.hideConnections' : 'map.showConnections')}
type="button"
onClick={e => { e.stopPropagation(); handler(res) }}
title={t('common.edit')}
className="bg-transparent text-content-faint"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
@@ -2256,11 +2266,36 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
<RouteIcon size={11} />
</button>
</Tooltip>
)}
{canEditDays && (() => {
const isTransport = TRANSPORT_TYPES.has(res.type)
const handler = isTransport ? onEditTransport : onEditReservation
if (!handler) return null
return (
<Tooltip label={t('common.edit')} placement="top">
<button aria-label={t('common.edit')}
type="button"
onClick={e => { e.stopPropagation(); handler(res) }}
className="bg-transparent text-content-faint"
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
</button>
</Tooltip>
)
})()}
</div>
@@ -2296,37 +2331,40 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</button>
</div>}
{canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && (
<button type="button"
onClick={e => {
e.stopPropagation()
onAddBookingToAssignment(day.id, assignment.id)
}}
title={t('reservations.addBooking')}
style={{
flexShrink: 0,
background: 'none',
border: '1px solid var(--border-primary)',
borderRadius: 5,
padding: '2px 6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 500,
color: 'var(--text-muted)',
fontFamily: 'inherit',
}}
>
<Plus size={11} strokeWidth={2} />
</button>
<Tooltip label={t('reservations.addBooking')} placement="top">
<button type="button" aria-label={t('reservations.addBooking')}
onClick={e => {
e.stopPropagation()
onAddBookingToAssignment(day.id, assignment.id)
}}
style={{
flexShrink: 0,
background: 'none',
border: '1px solid var(--border-primary)',
borderRadius: 5,
padding: '2px 6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 500,
color: 'var(--text-muted)',
fontFamily: 'inherit',
}}
>
<Plus size={11} strokeWidth={2} />
</button>
</Tooltip>
)}
</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])} 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>
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
<div role="button" tabIndex={0} 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' }} aria-label={t('dayplan.transportMode.change')}>
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
</div>
</Tooltip>
) : (
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
))}
@@ -2532,7 +2570,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
return next
})
}}
title={t(expanded ? 'common.collapse' : 'common.expand')}
aria-label={t(expanded ? 'common.collapse' : 'common.expand')}
style={{
flexShrink: 0, appearance: 'none', width: 26, height: 26, borderRadius: 6,
@@ -2549,24 +2586,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{!transitMeta && onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
const active = visibleConnectionIds.includes(res.id)
return (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
style={{
flexShrink: 0, appearance: 'none',
width: 26, height: 26, borderRadius: 6,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
background: active ? color : 'transparent',
color: active ? '#fff' : 'var(--text-faint)',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={13} />
</button>
<Tooltip label={t(active ? 'map.hideConnections' : 'map.showConnections')} placement="top">
<button aria-label={t(active ? 'map.hideConnections' : 'map.showConnections')}
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection(res.id) }}
style={{
flexShrink: 0, appearance: 'none',
width: 26, height: 26, borderRadius: 6,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
background: active ? color : 'transparent',
color: active ? '#fff' : 'var(--text-faint)',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={13} />
</button>
</Tooltip>
)
})()}
</div>
@@ -2580,9 +2618,11 @@ 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])} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id]))} style={{ cursor: 'pointer' }}>
{connector}
</div>
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
<div role="button" tabIndex={0} 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' }} aria-label={t('dayplan.transportMode.change')}>
{connector}
</div>
</Tooltip>
) : connector
})()}
</React.Fragment>
@@ -2717,9 +2757,11 @@ 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)} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.bottom!.seg))} style={{ cursor: 'pointer' }}>
{connector}
</div>
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
<div role="button" tabIndex={0} 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' }} aria-label={t('dayplan.transportMode.change')}>
{connector}
</div>
</Tooltip>
) : connector
})()}
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
@@ -2786,6 +2828,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onToggleRoute?.()
}
}}
aria-label={t('dayplan.route')}
className={routeActive ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
@@ -2795,81 +2838,85 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}}
>
<RouteIcon size={12} strokeWidth={2} />
{t('dayplan.route')}
{!narrowPanel && t('dayplan.route')}
</button>
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<button type="button"
onClick={() => {
const url = generateGoogleMapsUrl(dayExportStops())
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
title={t('planner.openGoogleMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<GoogleMapsIcon size={14} />
</button>
<Tooltip label={t('planner.openGoogleMaps')} placement="top">
<button type="button"
onClick={() => {
const url = generateGoogleMapsUrl(dayExportStops())
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<GoogleMapsIcon size={14} />
</button>
</Tooltip>
{/* The same day, handed to CoMaps for offline navigation (#1904). The
day's own travel mode rides along, so the route it builds walks
when the plan walks. */}
<button type="button"
onClick={() => {
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openCoMaps')}
title={t('planner.openCoMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<Compass size={14} strokeWidth={2} />
</button>
<Tooltip label={t('planner.openCoMaps')} placement="top">
<button type="button"
onClick={() => {
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openCoMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<Compass size={14} strokeWidth={2} />
</button>
</Tooltip>
{/* Icon-only, like the two map hand-offs beside it (#1981). It
was the one button here carrying a label with no room for
it: `flex: 1` alongside `padding: '6px 0'` meant the text
sat against both edges as soon as the row filled up, and
the row gained a button when CoMaps arrived. The label
stays as the accessible name and the tooltip. */}
<button type="button"
onClick={() => handleOptimize(day.id)}
aria-label={t('dayplan.optimize')}
title={t('dayplan.optimize')}
className="bg-surface-hover text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: 'none',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<RotateCcw size={14} strokeWidth={2} />
</button>
<Tooltip label={t('dayplan.optimize')} placement="top">
<button type="button"
onClick={() => handleOptimize(day.id)}
aria-label={t('dayplan.optimize')}
className="bg-surface-hover text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: 'none',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<RotateCcw size={14} strokeWidth={2} />
</button>
</Tooltip>
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
{routeProfileOptions.map(p => {
const ModeIcon = p.key === 'driving' ? Car : p.key === 'walking' ? Footprints : Zap
const active = (day.default_transport_mode ?? routeProfile) === p.key
return (
<button type="button"
key={p.key}
onClick={() => setDayDefaultMode(day.id, p.key)}
aria-label={p.label}
title={p.label}
className={active ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', border: 'none', cursor: 'pointer',
}}
>
<ModeIcon size={13} strokeWidth={2} />
</button>
<Tooltip label={p.label} placement="top">
<button type="button"
key={p.key}
onClick={() => setDayDefaultMode(day.id, p.key)}
aria-label={p.label}
className={active ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', border: 'none', cursor: 'pointer',
}}
>
<ModeIcon size={13} strokeWidth={2} />
</button>
</Tooltip>
)
})}
</div>
@@ -22,6 +22,8 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { getCategoryIcon } from '../shared/categoryIcons'
import DawarichIcon from '../shared/DawarichIcon'
import { Tooltip } from '../shared/Tooltip'
import { useToast } from '../shared/Toast'
import { useTranslation, translateApiError } from '../../i18n'
import { usePluginStore } from '../../store/pluginStore'
@@ -796,6 +798,17 @@ function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameIn
style={{ fontWeight: 600, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', lineHeight: '1.3', cursor: onUpdatePlace ? 'text' : 'default' }}
>{place.name}</span>
)}
{/* Where the place came from, when it did not come from somebody
typing it: a stay accepted out of their own recordings. The
mark alone — the name beside it is already the place's name,
and a word here would only repeat the tooltip. */}
{place.source === 'dawarich' && (
<Tooltip label={t('dawarich.place.fromDawarich')} placement="top">
<span style={{ display: 'inline-flex', flexShrink: 0, overflow: 'hidden', borderRadius: 5 }}>
<DawarichIcon size={16} />
</span>
</Tooltip>
)}
{category && (() => {
const CatIcon = getCategoryIcon(category.icon)
return (
@@ -10,6 +10,10 @@ import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker'
import { ListImportModal } from './PlacesSidebarListImportModal'
import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal'
import SaveTripPlacesToListModal from '../Collections/SaveTripPlacesToListModal'
import DawarichSuggestionsPanel from '../Dawarich/DawarichSuggestionsPanel'
import { formatDayOption } from '../Dawarich/dawarichSuggestionModel'
import { refreshTripAfterAccept } from '../Dawarich/dawarichTripRefresh'
import { useTranslation } from '../../i18n'
const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProps) {
const S = usePlacesSidebar(props)
@@ -19,8 +23,10 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
collectionsEnabled, saveToListOpen, setSaveToListOpen,
collectionsEnabled, saveToListOpen, setSaveToListOpen, days,
} = S
// The sidebar hook carries `t` but not the locale; day labels need both.
const { locale } = useTranslation()
// Below lg the places sit in their own tab with no plan beside them to drag
// into. A coarse pointer no longer disables the drag on its own — tablets
// reach it through a long press (#1616).
@@ -59,6 +65,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
</div>
)}
{/* Stays Dawarich recorded on these dates, waiting to be reviewed (#2279).
Above the list because that is what they become, and the panel renders
nothing at all when there is nothing pending — an integration that is
connected and quiet should be invisible rather than a permanent empty
card. */}
<div style={{ padding: '0 12px 8px', flexShrink: 0 }}>
<DawarichSuggestionsPanel
tripId={tripId}
trips={[{ id: tripId, label: t('dawarich.accept.thisTrip') }]}
daysForTrip={() => days.map(day => ({
id: day.id,
...formatDayOption(day.day_number, day.date, locale, t),
}))}
// The place it just created belongs on the map and in the list
// now, not after a reload.
onAccepted={() => { void refreshTripAfterAccept(tripId) }}
initiallyCollapsed
/>
</div>
{/* Liste */}
<PlacesList {...S} />