feat(places): offer Waze and Apple Maps beside Google Maps (#1885)

* Offer Waze and Apple Maps beside Google Maps

A place could only be opened in Google Maps, and the request in #919 was for the
apps people actually drive with. Google keeps the precise link it always had:
ftid, then place id, then the details URL, and coordinates only as a last resort,
so it still lands on the entry inside a mall rather than on the roof. Waze and
Apple Maps take coordinates and nothing else, which is why they are absent for a
place with no position, and why they open with navigation armed instead of on a
place view.

No setting. Every app that can resolve the place is offered, except Apple Maps,
which is dead weight on Android and Windows and therefore only appears on Apple
platforms. iPadOS reports itself as a Mac; both sides of that ambiguity have the
app, so the check does not need to tell them apart.

The picker is a new component rather than the context menu, which has no notion
of a submenu. It is portalled because both call sites sit in panels that clip
their overflow, and it is measured before it is painted: near the bottom of the
window it flips above the trigger instead of running off the screen, and it is
pulled back inside when it would overflow to the right. With a single app left
to offer there is no menu at all, the button opens it directly, exactly as it
behaved before.

The three surfaces take it the way each has room for. The inspector button
collects them behind one entry, the day-plan context menu lists them flat
because a menu can grow, and the phone keeps its single circle and opens the
picker as a sheet.

* Move OpenStreetMap into the navigation menu, and name the destination

OSM was still a button of its own beside the one that now collects the map apps,
which is the arrangement the menu exists to end. It belongs with the others.

The bigger correction is what Waze and Apple Maps are handed. Both document a
query parameter next to the position, and the pair is what makes a destination
legible: coordinates say which place is meant, the name is what the driver reads
instead of a number. Neither gets a name without a position, because there are a
lot of places called "Bahnhof". Google is unchanged; it can already do better
than a name through its place id.

* Stop drawing the section count as a pill inside a pill

SectionHeader wraps its count in a CountPill, and all three callers passed one
in already, so every section header carried two stacked pills: a lighter
rounded background peeking out from behind the real one. Pass the number and
let the header do the wrapping.

* Give the traveler filter a label and a way back

The avatars floated between the toolbar and the first section with nothing to
say what they were, and once a filter was on the only way out was remembering
which face had been tapped. It is a labelled bar now, closed off by the same
divider the rows below use, with an explicit reset that appears while a filter
is active. Both texts already exist in all 23 languages.

* Line the traveler filter up with the section titles

It carried the sheet's usual 18px of horizontal padding while SectionHeader
right below it starts at 2, so the two labels sat on different left edges.

* Filter the place list by stars instead of sorting by them

Sorting put the best-rated place first and left every other one on the list,
which does not help when the question is "what does the group actually want to
do". It is a floor now, the same one the collections bar uses: All, or 5+ down
to 1+, with unrated places dropping out once a floor is set. The button carries
the active floor so the list is never quietly shortened.

The row it sits in used to render only when the trip had categories, which hid
the star control on a fresh trip. That is exactly where someone collects places
to rate, so the row is always there now and only the category dropdown depends
on there being categories.

places.sortByRating is replaced by places.filterByRating in all 23 languages,
since the old label described a behaviour that no longer exists.

* Keep the collections map when a filter matches nothing

Clicking a label with no places behind it made the map disappear and the page
reflow to full width. The layout hung off the FILTERED set: no visible places
meant nothing mappable meant no split. A filter should empty the map, not tear
it out, so the layout now asks whether the list has anything mappable at all,
while the markers keep following the filter.

* Give the label filters a tray of their own

Two changes to the filter row. Select and manage-labels drop their captions and
keep icon, title and aria-label, which puts them on the same footing as the
add-place button that was already icon-only.

The labels themselves were pill-shaped buttons with their own borders sitting
loose among square-cornered dropdowns, so a list with a handful of them read as
a pile rather than as one control. They share the filter buttons' radius and
type now, and sit in a recessed strip led by a tag glyph: the chips carry no
border of their own, because a frame inside a frame was half the noise. Manage
stays quiet at the end of the strip until hovered.

* Put the collections controls on one line

The filter row sat at about 31px while the buttons floating over the map are
38, and the icon-only add button was the odd one out even within its own row.
Scrolled to the top, where the row and the map's top bar are side by side, that
reads as three different heights.

One --col-ctl-h for both, fixed rather than derived from padding so a button
with an icon and one with a word cannot drift apart, and the row starts at the
same offset the map's own bar does. Phones raise the variable instead of
patching individual min-heights, which keeps the square buttons square.

* Offer Apple Maps on desktops that are not Apple

Apple Maps has had a web version since 2024, so the link resolves to a usable
map on Windows and Linux rather than to nothing. Hiding it there was a guess
about a limitation that no longer exists.

Android keeps the exemption: the web version works there too, but nobody
navigating from an Android phone reaches for Apple Maps, and the row of choices
is short on purpose.

* Move the label filter into the map's top bar

The filter row was running out of room while the map's own bar had space to
spare, so the labels ride up there next to the collapse button, in the same
glass as the controls beside them and scrolling sideways rather than wrapping.

They fall back into the filter row whenever no map is on screen — a narrow
window in list view, or a list with nothing mappable in it — so they can never
end up somewhere the user cannot reach. Both places render the same component
now instead of two copies drifting apart.

* Stop the active select button drawing a dark icon on a dark fill

Two rules with the same specificity claimed the same class. On a dropdown
`.open` means the panel is down, so the label stays dark; on the select button
it meant the mode is on, which fills it with the accent and needs the light
text. The dropdown rule sits later in the file, so it won, and the icon
disappeared into the fill the moment the button was clicked.

The state is `.on` now, which is what the other toggles on this page
(col-map-btn, col-iconbtn) already call it.

* Open the place in Google Maps, not the coordinates

Reported in #1278 and still reproducible: without a Google identifier the link
carried nothing but latitude and longitude, so Maps dropped an unlabelled pin
instead of opening the entry. The name and the address were both on hand and
both thrown away.

Google documents query=PLACE_NAME,ADDRESS for exactly this case, so that is
what the fallback sends now. A name with no address still falls through to
coordinates, because "Central Station" on its own would confidently open the
wrong city, and a pin in the right spot beats a link to the wrong place.

The precise paths are untouched: an ftid or a place id still wins, and they are
what a place searched through Google carries. This only changes what happens
for the ones that carry neither, which is every place added from OpenStreetMap,
by hand, or before those fields existed.
This commit is contained in:
Maurice
2026-08-11 16:00:03 +02:00
committed by GitHub
parent 8b1a238c51
commit d1e3d8a75a
76 changed files with 1087 additions and 179 deletions
@@ -131,4 +131,16 @@ describe('CollectionFilterBar', () => {
render(<Harness {...makeProps({ showSelect: false })} />);
expect(screen.queryByRole('button', { name: 'Select' })).not.toBeInTheDocument();
});
it('FE-COMP-COLFILTERBAR-009: the active Select button marks itself with `on`, not `open`', () => {
// `.open` belongs to the dropdowns, where it means the panel is down and the
// label stays dark. Sharing that class made the active button paint a dark
// icon on its dark accent fill, because the dropdown rule sits later in the
// stylesheet and both have the same specificity.
render(<Harness {...makeProps({ selectMode: true })} />);
const selectBtn = screen.getByRole('button', { name: 'Select' });
expect(selectBtn).toHaveClass('on');
expect(selectBtn).not.toHaveClass('open');
expect(selectBtn).toHaveAttribute('aria-pressed', 'true');
});
});
@@ -1,10 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react'
import { ChevronDown, Check, Layers, Tag, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react'
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
import type { TranslationFn } from '../../types'
import { getCategoryIcon } from '../shared/categoryIcons'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel'
import CollectionLabelFilter from './CollectionLabelFilter'
interface Opt {
key: string | number
@@ -148,36 +149,26 @@ export default function CollectionFilterBar({
<Dropdown current={ratingFilter} options={ratingOpts} onSelect={k => onRatingFilter(k as number | 'all')} lead={<Star size={13} />} />
<Dropdown current={sortMode} options={sortOpts} onSelect={k => onSortMode(k as CollectionSortMode)} lead={<ArrowDownUp size={13} />} />
{showSelect && (
<button type="button" onClick={onToggleSelect} className={`col-filter-btn col-filter-select${selectMode ? ' open' : ''}`} aria-pressed={selectMode}>
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
<button
type="button"
onClick={onToggleSelect}
className={`col-filter-btn col-filter-icon col-filter-select${selectMode ? ' on' : ''}`}
aria-pressed={selectMode}
aria-label={t('collections.select')}
title={t('collections.select')}
>
<CheckSquare size={15} />
</button>
)}
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
<div className="col-labelfilter">
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button
key={l.id}
type="button"
className={`col-labelchip${on ? ' on' : ''}`}
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
aria-pressed={on}
>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</button>
)
})}
{canManageLabels && (
<button type="button" className="col-filter-btn col-filter-addlabel" onClick={onManageLabels} title={t('collections.labels.manage')}>
<Tags size={13} />
<span className="col-filter-lbl">{labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}</span>
</button>
)}
</div>
<CollectionLabelFilter
labelOptions={labelOptions}
labelFilter={labelFilter}
onLabelFilter={onLabelFilter}
canManageLabels={canManageLabels}
onManageLabels={onManageLabels}
t={t}
/>
)}
</div>
)
@@ -0,0 +1,72 @@
import React from 'react'
import { Plus, Settings2, Tags } from 'lucide-react'
import type { LabelOption } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types'
export type { LabelOption }
interface CollectionLabelFilterProps {
labelOptions: LabelOption[]
labelFilter: number[]
onLabelFilter: (ids: number[]) => void
canManageLabels?: boolean
onManageLabels?: () => void
/**
* 'bar' is the filter row above the list, 'map' the strip floating over the
* map, which needs the same glass treatment as the buttons beside it.
*/
variant?: 'bar' | 'map'
t: TranslationFn
}
/**
* The label filter, in one place because it has two homes.
*
* Labels are an axis of their own next to status, category and rating, so they
* are grouped into a tray instead of joining the row as more loose buttons. On
* a desktop it rides in the map's top bar, where there is room to spare; the
* filter row keeps it whenever no map is on screen, so it never becomes
* unreachable.
*/
export default function CollectionLabelFilter({
labelOptions, labelFilter, onLabelFilter, canManageLabels = false, onManageLabels,
variant = 'bar', t,
}: CollectionLabelFilterProps): React.ReactElement {
return (
<div
className={`col-labelfilter${variant === 'map' ? ' on-map' : ''}`}
role="group"
aria-label={t('collections.labels.manage')}
>
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button
key={l.id}
type="button"
className={`col-labelchip${on ? ' on' : ''}`}
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
aria-pressed={on}
>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</button>
)
})}
{canManageLabels && onManageLabels && (
<button
type="button"
className="col-filter-addlabel"
onClick={onManageLabels}
title={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
aria-label={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
</button>
)}
</div>
)
}
@@ -3,6 +3,7 @@ import { PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react'
import type { CollectionPlace } from '@trek/shared'
import type { TranslationFn } from '../../types'
import CollectionMap from './CollectionMap'
import CollectionLabelFilter, { type LabelOption } from './CollectionLabelFilter'
interface CollectionMapPanelProps {
places: CollectionPlace[]
@@ -17,6 +18,16 @@ interface CollectionMapPanelProps {
onToggleView: () => void
search: string
onSearch: (v: string) => void
/**
* The label filter rides along in the map's top bar, where there is room for
* it. The filter row keeps it whenever no map is on screen, so it can never
* become unreachable.
*/
labelOptions?: LabelOption[]
labelFilter?: number[]
onLabelFilter?: (ids: number[]) => void
canManageLabels?: boolean
onManageLabels?: () => void
t: TranslationFn
}
@@ -27,8 +38,10 @@ interface CollectionMapPanelProps {
*/
export default function CollectionMapPanel({
places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView,
search, onSearch, t,
search, onSearch, labelOptions = [], labelFilter = [], onLabelFilter, canManageLabels = false,
onManageLabels, t,
}: CollectionMapPanelProps): React.ReactElement {
const showLabels = onLabelFilter != null && (labelOptions.length > 0 || canManageLabels)
return (
<div className="col-map-shell">
<CollectionMap
@@ -50,6 +63,17 @@ export default function CollectionMapPanel({
>
{view === 'map' ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
</button>
{showLabels && (
<CollectionLabelFilter
variant="map"
labelOptions={labelOptions}
labelFilter={labelFilter}
onLabelFilter={onLabelFilter!}
canManageLabels={canManageLabels}
onManageLabels={onManageLabels}
t={t}
/>
)}
</div>
<div className="col-map-group right">
<div className="col-map-search">
@@ -43,7 +43,7 @@ import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDet
import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay'
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
interface DayPlanSidebarProps {
tripId: number
@@ -1701,12 +1701,15 @@ 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) }}
onContextMenu={e => {
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
// Flat entries, one per app: the menu has room to
// grow and a submenu would need a component that
// does not exist here.
const navTargets = getNavigationTargets(place)
ctxMenu.open(e, [
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
googleMapsUrl && { label: t('inspector.google'), icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
@@ -1,4 +1,4 @@
import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render';
import { render, screen, waitFor, fireEvent, act, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { buildUser, buildTrip, buildPlace, buildCategory, buildReservation } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
@@ -632,13 +632,18 @@ describe('PlaceInspector', () => {
// ── Google Maps URL action ─────────────────────────────────────────────────
it('FE-PLANNER-INSPECTOR-043: Google Maps lat/lng button visible when no google_maps_url', () => {
it('FE-PLANNER-INSPECTOR-043: the navigation button offers every app the place can be opened in', async () => {
const user = userEvent.setup();
render(<PlaceInspector {...defaultProps} />);
// place has lat/lng so Google Maps button should appear with Navigation icon
const allButtons = screen.getAllByRole('button');
// Find button containing "Google Maps" text
const mapsBtn = allButtons.find(btn => btn.textContent?.includes('Google Maps'));
expect(mapsBtn).toBeTruthy();
// A place with coordinates reaches Google Maps and Waze (Apple Maps only on
// Apple platforms), so the button collects them behind one entry.
const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!;
expect(navBtn).toBeTruthy();
await user.click(navBtn);
const menu = await screen.findByRole('menu');
expect(within(menu).getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument();
expect(within(menu).getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument();
});
it('FE-PLANNER-INSPECTOR-043b: Google Maps action uses google_ftid over coordinates', async () => {
@@ -651,9 +656,10 @@ describe('PlaceInspector', () => {
lng: -80.5542617,
google_ftid: '0x882bf179e806d471:0x8591dde29c821a93',
})} />);
const mapsBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Google Maps'))!;
await user.click(mapsBtn);
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank');
const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!;
await user.click(navBtn);
await user.click(await screen.findByRole('menuitem', { name: 'Google Maps' }));
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank', 'noopener,noreferrer');
openSpy.mockRestore();
});
@@ -934,12 +940,17 @@ describe('PlaceInspector', () => {
// ── Footer actions ───────────────────────────────────────────────────────────
it('FE-PLANNER-INSPECTOR-069: the OpenStreetMap and website buttons open their targets in a new tab', () => {
it('FE-PLANNER-INSPECTOR-069: OpenStreetMap sits in the navigation menu, the website keeps its own button', async () => {
const user = userEvent.setup();
const open = vi.fn();
vi.stubGlobal('open', open);
const p = buildPlace({ id: 705, name: 'Linked Place', osm_id: 'node/12345', website: 'https://example.org' });
render(<PlaceInspector {...defaultProps} place={p} />);
fireEvent.click(screen.getByText('OpenStreetMap').closest('button')!);
// OSM moved in with the other map apps rather than standing beside them.
await user.click(screen.getAllByRole('button').find(b => b.textContent?.includes('Navigation'))!);
await user.click(await screen.findByRole('menuitem', { name: 'OpenStreetMap' }));
fireEvent.click(screen.getByText('Open Website').closest('button')!);
expect(open).toHaveBeenCalledTimes(2);
expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank']);
@@ -27,8 +27,8 @@ import type { CollectionStatus } from '@trek/shared'
import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters'
import { useTripStore } from '../../store/tripStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { NavigationMenu } from '../shared/NavigationMenu'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine } from './placeHoursFormat'
@@ -178,6 +178,8 @@ export default function PlaceInspector({
// Extra native rows contributed by placeDetailProvider plugins (#1429). Fail-safe:
// any provider error/timeout is dropped server-side, so this only ever adds rows.
const [providerDetails, setProviderDetails] = useState<Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }>>([])
const [navOpen, setNavOpen] = useState(false)
const navBtnRef = useRef<HTMLButtonElement>(null)
const placeIdForDetails = mode === 'trip' ? place?.id : undefined
useEffect(() => {
if (placeIdForDetails == null) { setProviderDetails([]); return }
@@ -316,11 +318,10 @@ export default function PlaceInspector({
googleDetails?.open_now,
)
// Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google.
const googleMapsUrl = getGoogleMapsUrlForPlace(
const navigationTargets = getNavigationTargets(
place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null,
googleDetails?.google_maps_url,
)
const openStreetMapUrl = getOpenStreetMapUrlForPlace(place)
const selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date, placeTimeZone)
@@ -484,13 +485,32 @@ export default function PlaceInspector({
icon={savedInCollection ? <BookmarkCheck size={13} /> : <Bookmark size={13} />}
label={<span className="hidden sm:inline">{savedInCollection ? t('inspector.savedToCollection') : t('inspector.saveToCollection')}</span>} />
)}
{googleMapsUrl && (
<ActionButton onClick={() => window.open(googleMapsUrl, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
)}
{openStreetMapUrl && (
<ActionButton onClick={() => window.open(openStreetMapUrl, '_blank')} variant="ghost" icon={<MapIcon size={13} />}
label={<span className="hidden sm:inline">{t('inspector.openStreetMap')}</span>} />
{navigationTargets.length > 0 && (
<>
{/* One target left (a place without coordinates) opens straight
away, exactly as this button always did. */}
<ActionButton
ref={navBtnRef}
onClick={() => {
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
else setNavOpen(o => !o)
}}
variant="ghost"
icon={<Navigation size={13} />}
label={
<span className="hidden sm:inline">
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
</span>
}
/>
{navOpen && (
<NavigationMenu
targets={navigationTargets}
anchor={navBtnRef.current}
onClose={() => setNavOpen(false)}
/>
)}
</>
)}
{(place.website || googleDetails?.website) && (
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
@@ -535,9 +555,11 @@ interface ActionButtonProps {
variant: 'primary' | 'ghost' | 'danger'
icon: React.ReactNode
label: React.ReactNode
/** For callers that anchor a popup to the button. React 19 passes it through. */
ref?: React.Ref<HTMLButtonElement>
}
export function ActionButton({ onClick, variant, icon, label }: ActionButtonProps) {
export function ActionButton({ onClick, variant, icon, label, ref }: ActionButtonProps) {
const base = {
primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' },
ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' },
@@ -546,6 +568,7 @@ export function ActionButton({ onClick, variant, icon, label }: ActionButtonProp
const s = base[variant] || base.ghost
return (
<button
ref={ref}
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 5,
@@ -672,6 +672,28 @@ describe('track colour legend (#776)', () => {
const strokes = Array.from(row.querySelectorAll('span')).filter(el => (el as HTMLElement).style.borderRadius === '999px');
expect(strokes).toHaveLength(0);
});
it('FE-PLANNER-SIDEBAR-051: the star button filters the list down to a minimum rating', async () => {
// Replaces the old sort toggle: sorting put the best first but still left
// every other place on the list, which is no help when the point is to see
// only what the group actually wants to do.
const user = userEvent.setup();
const places = [
buildPlace({ id: 20, name: 'Loved Place', rating_avg: 4.6 }),
buildPlace({ id: 21, name: 'Meh Place', rating_avg: 2.1 }),
buildPlace({ id: 22, name: 'Unrated Place' }),
];
render(<PlacesSidebar {...defaultProps} places={places} />);
expect(screen.getByText('Meh Place')).toBeInTheDocument();
await user.click(screen.getByLabelText('Filter by rating'));
await user.click(screen.getByText('4+'));
expect(screen.getByText('Loved Place')).toBeInTheDocument();
expect(screen.queryByText('Meh Place')).not.toBeInTheDocument();
// An unrated place has no average to clear the floor with.
expect(screen.queryByText('Unrated Place')).not.toBeInTheDocument();
});
});
// #1616 — the other half of the reporter's gesture: the pickup. A tablet cannot
@@ -25,7 +25,8 @@ export function PlacesHeader(S: SidebarState) {
places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks,
filter, setFilter, setSelectedIds, selectMode, setSelectMode,
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters,
ratingSort, setRatingSort,
ratingFilter, setRatingFilter,
starDropOpen, setStarDropOpen,
} = S
return (
<div className="border-b border-edge-faint" style={{ padding: '14px 16px 10px', flexShrink: 0 }}>
@@ -150,8 +151,10 @@ export function PlacesHeader(S: SidebarState) {
)}
</div>
{/* Category multi-select dropdown */}
{categories.length > 0 && (() => {
{/* Category multi-select plus the star filter. Rendered even without any
category, because the rating filter does not depend on one and a fresh
trip is exactly where someone collects places to rate. */}
{(() => {
const label = categoryFilters.size === 0
? t('places.allCategories')
: categoryFilters.size === 1
@@ -159,7 +162,7 @@ export function PlacesHeader(S: SidebarState) {
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
return (
<div style={{ marginTop: 6, position: 'relative', display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button onClick={() => setCatDropOpen(v => !v)} className="bg-surface-card text-content" style={{
{categories.length > 0 && <button onClick={() => setCatDropOpen(v => !v)} className="bg-surface-card text-content" style={{
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
@@ -167,26 +170,64 @@ export function PlacesHeader(S: SidebarState) {
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<ChevronDown size={12} className="text-content-faint" style={{ flexShrink: 0, transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{/* Star sort (#1435): order the list by average rating, best first. */}
<Tooltip label={t('places.sortByRating')} placement="bottom">
</button>}
{/* Minimum-stars filter (#1435), same floors as the collections bar. */}
<Tooltip label={t('places.filterByRating')} placement="bottom">
<button
onClick={() => setRatingSort(v => !v)}
aria-label={t('places.sortByRating')}
aria-pressed={ratingSort}
className={ratingSort ? 'text-accent' : 'text-content-faint'}
onClick={() => { setStarDropOpen(v => !v); setCatDropOpen(false) }}
aria-label={t('places.filterByRating')}
aria-expanded={starDropOpen}
className={ratingFilter !== 'all' ? 'text-accent' : 'text-content-faint'}
style={{
width: 30, flexShrink: 0, borderRadius: 8,
border: `1px solid ${ratingSort ? 'var(--accent)' : 'var(--border-primary)'}`,
background: ratingSort ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
height: 30, flexShrink: 0, borderRadius: 8, gap: 3,
padding: ratingFilter === 'all' ? 0 : '0 7px',
width: ratingFilter === 'all' ? 30 : undefined,
border: `1px solid ${ratingFilter !== 'all' ? 'var(--accent)' : 'var(--border-primary)'}`,
background: ratingFilter !== 'all' ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.18s, color 0.18s, border-color 0.18s',
}}
>
<Star size={13} strokeWidth={2.2} fill={ratingSort ? 'currentColor' : 'none'} />
<Star size={13} strokeWidth={2.2} fill={ratingFilter !== 'all' ? 'currentColor' : 'none'} />
{ratingFilter !== 'all' && <span>{ratingFilter}+</span>}
</button>
</Tooltip>
{starDropOpen && (
<div className="bg-surface-card" style={{
position: 'absolute', top: '100%', right: 0, zIndex: 50, marginTop: 4, minWidth: 116,
border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4,
}}>
{(['all', 5, 4, 3, 2, 1] as const).map(opt => {
const active = ratingFilter === opt
return (
<button
key={String(opt)}
onClick={() => { setRatingFilter(opt as number | 'all'); setStarDropOpen(false) }}
className={`text-content ${active ? 'bg-surface-hover' : 'bg-transparent'}`}
style={{
display: 'flex', alignItems: 'center', gap: 7, width: '100%',
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
textAlign: 'left',
}}
>
{opt === 'all'
? <span style={{ flex: 1 }}>{t('common.all')}</span>
: (
<>
<Star size={12} strokeWidth={2.2} color="#facc15" fill="#facc15" />
<span style={{ flex: 1 }}>{opt}+</span>
</>
)}
{active && <Check size={11} strokeWidth={3} className="text-content-faint" />}
</button>
)
})}
</div>
)}
{canEditPlaces && (
<Tooltip label={t('common.select')} placement="bottom">
<button
@@ -222,7 +263,7 @@ export function PlacesHeader(S: SidebarState) {
</button>
</Tooltip>
)}
{catDropOpen && (
{catDropOpen && categories.length > 0 && (
<div className="bg-surface-card" style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
border: '1px solid var(--border-primary)', borderRadius: 10,
@@ -24,7 +24,16 @@ describe('getGoogleMapsUrlForPlace', () => {
expect(url).toBe('https://maps.google.com/?cid=123')
})
it('FE-PLACE-GMAPS-005: falls back to coordinates as a last resort', () => {
it('FE-PLACE-GMAPS-005: searches by name and address when Google knows the place by neither id', () => {
// #1278: coordinates alone open a nameless pin rather than the place, and
// Google documents query=PLACE_NAME,ADDRESS for precisely this case.
const url = getGoogleMapsUrlForPlace({ ...base, address: 'Champ de Mars, 75007 Paris' })
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower%2C%20Champ%20de%20Mars%2C%2075007%20Paris')
})
it('FE-PLACE-GMAPS-005b: a name with no address still falls back to coordinates', () => {
// "Central Station" on its own would happily match a different city; the
// position is the safer answer even though the pin carries no label.
const url = getGoogleMapsUrlForPlace(base)
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=48.8584,2.2945')
})
@@ -33,4 +42,9 @@ describe('getGoogleMapsUrlForPlace', () => {
expect(getGoogleMapsUrlForPlace(null)).toBeNull()
expect(getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null })).toBeNull()
})
it('FE-PLACE-GMAPS-007: an address alone carries the link even without coordinates', () => {
const url = getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null, address: 'Champ de Mars, 75007 Paris' })
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower%2C%20Champ%20de%20Mars%2C%2075007%20Paris')
})
})
@@ -1,6 +1,6 @@
import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i
export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, detailsUrl?: string | null): string | null {
@@ -14,6 +14,19 @@ export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, de
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.name)}&query_place_id=${encodeURIComponent(placeId)}`
}
if (detailsUrl) return detailsUrl
// No Google identifier of any kind. Coordinates alone drop a pin on a spot
// with no name attached, which is what #1278 reported: the map opens, but not
// the place. Google documents `query=PLACE_NAME,ADDRESS` for exactly this
// case, and an address is specific enough that the name cannot match the
// wrong "Central Station" three cities over.
const name = place.name?.trim()
const address = place.address?.trim()
if (name && address) {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${name}, ${address}`)}`
}
if (place.lat == null || place.lng == null) return null
// A name without an address is not safe to search on its own, so the position
// wins: a pin in the right spot beats a confident link to the wrong place.
return `https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`
}
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getNavigationTargets, showsAppleMaps } from './placeNavigation'
import type { Place } from '../../types'
// FE-PLANNER-NAV-001 to FE-PLANNER-NAV-008
function place(overrides: Partial<Place> = {}): Place {
return {
name: 'Stephansdom',
lat: 48.2038,
lng: 16.3616,
google_place_id: null,
google_ftid: null,
...overrides,
} as unknown as Place
}
/**
* jsdom reports its own agent, so Apple has to be simulated. userAgent lives on
* the prototype as a getter: shadowing it on the instance and deleting that
* again afterwards restores the real one without having to reconstruct it.
*/
function withUserAgent(ua: string) {
Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true })
return () => { delete (navigator as unknown as Record<string, unknown>).userAgent }
}
afterEach(() => { vi.restoreAllMocks() })
describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-001: offers every app that can resolve a place with coordinates', () => {
const targets = getNavigationTargets(place())
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets[0].label).toBe('Google Maps')
})
it('FE-PLANNER-NAV-002: Waze gets the name as well as the position, and navigates', () => {
// Coordinates alone would show a driver a number instead of a destination;
// the name alone would be a guess. Waze documents taking both.
const waze = getNavigationTargets(place()).find(t => t.id === 'waze')!
expect(waze.url).toBe('https://waze.com/ul?q=Stephansdom&ll=48.2038,16.3616&navigate=yes')
})
it('FE-PLANNER-NAV-003: Google keeps its precise link when the place has an ftid', () => {
const targets = getNavigationTargets(place({ google_ftid: '0x882b:0x8591' }))
// The whole point of the ftid: it lands on the entry, not on the roof.
expect(targets[0].url).toContain('ftid=0x882b:0x8591')
expect(targets[0].url).not.toContain('query=48.2038')
})
it('FE-PLANNER-NAV-004: a Windows desktop gets Apple Maps too, through its web version', () => {
const restore = withUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
try {
expect(getNavigationTargets(place()).map(t => t.id)).toContain('apple')
} finally { restore() }
})
it('FE-PLANNER-NAV-004b: an Android phone does not, because nobody there wants it', () => {
const restore = withUserAgent('Mozilla/5.0 (Linux; Android 15; Pixel 9)')
try {
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm'])
} finally { restore() }
})
it('FE-PLANNER-NAV-005: an iPhone gets Apple Maps with a navigation target', () => {
const restore = withUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')
try {
const targets = getNavigationTargets(place())
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
// q next to ll labels the pin rather than searching blindly.
expect(targets[2].url).toBe('https://maps.apple.com/?q=Stephansdom&ll=48.2038,16.3616')
} finally { restore() }
})
it('FE-PLANNER-NAV-006: iPadOS reports itself as a Mac, and both deserve the entry', () => {
const restore = withUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
try {
expect(showsAppleMaps()).toBe(true)
} finally { restore() }
})
it('FE-PLANNER-NAV-007: a place without coordinates keeps only what Google can resolve', () => {
// Waze and Apple Maps take coordinates and nothing else, so they cannot
// offer anything here — Google still can, through the place id.
// OpenStreetMap still manages a name search, the two driving apps do not.
const targets = getNavigationTargets(place({ lat: null, lng: null, google_place_id: 'ChIJabc' }))
expect(targets.map(t => t.id)).toEqual(['google', 'osm'])
})
it('FE-PLANNER-NAV-008: no place at all yields nothing', () => {
expect(getNavigationTargets(null)).toEqual([])
expect(getNavigationTargets(place({ lat: null, lng: null, name: '' }))).toEqual([])
})
it('FE-PLANNER-NAV-009: a nameless place still reaches every app, just without a label', () => {
const targets = getNavigationTargets(place({ name: '' }))
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets.find(t => t.id === 'waze')!.url).toBe('https://waze.com/ul?ll=48.2038,16.3616&navigate=yes')
})
})
@@ -0,0 +1,95 @@
import type { AssignmentPlace, Place } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm'
export interface NavigationTarget {
id: NavigationAppId
/** Product name. Not translated in any language, so it carries no i18n key. */
label: string
url: string
}
/**
* Whether Apple Maps is worth offering.
*
* Apple platforms open the installed app. Everywhere else the link still works,
* because Apple Maps has had a web version since 2024, so a Windows or Linux
* desktop gets a perfectly usable map rather than a dead end.
*
* Android is the one place it stays hidden: the web version works there too,
* but nobody navigating from an Android phone reaches for Apple Maps, and the
* row of choices is short for a reason.
*
* iPadOS 13 and later report themselves as "Macintosh", which is why the Mac
* branch is not narrowed by touch: a real Mac has the app, an iPad has the app,
* so both sides of that ambiguity are correct.
*/
export function showsAppleMaps(): boolean {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent
if (/iPhone|iPad|iPod|Macintosh/.test(ua)) return true
return !/Android/i.test(ua)
}
/**
* The map apps a place can be opened in, in the order they are offered.
*
* None of them gets bare coordinates. Waze and Apple Maps both take a query
* alongside the position (`q` in each case, documented by both), and the pair
* is what makes the destination legible: the coordinates anchor which place is
* meant, the name is what the driver sees on the screen instead of a number.
* Without the position a name alone would be a gamble — there are a lot of
* places called "Bahnhof" — so a place TREK has no coordinates for reaches
* neither app.
*
* Google is the exception and keeps the link it always had, because it can do
* better than a name: `getGoogleMapsUrlForPlace` walks ftid, then place id,
* then the details URL, which lands on the right entry inside a mall rather
* than on the roof.
*
* Waze arms navigation, since driving is the only thing it does. The other
* three open the place, which is what Google has always done here, and starting
* navigation from there is one tap.
*/
export function getNavigationTargets(
place: PlaceLike | null | undefined,
detailsUrl?: string | null,
): NavigationTarget[] {
if (!place) return []
const targets: NavigationTarget[] = []
const name = place.name?.trim()
const googleUrl = getGoogleMapsUrlForPlace(place, detailsUrl)
if (googleUrl) targets.push({ id: 'google', label: 'Google Maps', url: googleUrl })
if (place.lat != null && place.lng != null) {
const ll = `${place.lat},${place.lng}`
const q = name ? `q=${encodeURIComponent(name)}&` : ''
targets.push({
id: 'waze',
label: 'Waze',
url: `https://waze.com/ul?${q}ll=${ll}&navigate=yes`,
})
if (showsAppleMaps()) {
targets.push({
id: 'apple',
label: 'Apple Maps',
url: `https://maps.apple.com/?${q}ll=${ll}`,
})
}
}
const osmUrl = getOpenStreetMapUrlForPlace(place)
if (osmUrl) targets.push({ id: 'osm', label: 'OpenStreetMap', url: osmUrl })
return targets
}
/** Opens a target the way every external link in the planner is opened. */
export function openNavigationTarget(target: NavigationTarget): void {
window.open(target.url, '_blank', 'noopener,noreferrer')
}
@@ -187,25 +187,29 @@ describe('usePlacesSidebar filtering', () => {
expect([...useTripStore.getState().placesCategoryFilter]).toEqual([]);
});
it('FE-PLANNER-PSHOOK-012: the star sort puts the best rating first and unrated places last', () => {
it('FE-PLANNER-PSHOOK-012: the star filter keeps everything at or above the floor', () => {
const places = [
buildPlace({ name: 'Unrated' }),
buildPlace({ name: 'Good', rating_avg: 4.2 }),
buildPlace({ name: 'Best', rating_avg: 4.9 }),
buildPlace({ name: 'Meh', rating_avg: 2.5 }),
];
render(<Host {...makeProps({ places })} />);
act(() => { S.setRatingSort(true); });
expect(names()).toEqual(['Best', 'Good', 'Unrated']);
act(() => { S.setRatingFilter(4); });
// The list keeps its own order; the filter only decides who is on it.
expect(names()).toEqual(['Good', 'Best']);
});
it('FE-PLANNER-PSHOOK-013: equal ratings keep the incoming order', () => {
it('FE-PLANNER-PSHOOK-013: an unrated place drops out as soon as a floor is set, and comes back with "all"', () => {
const places = [
buildPlace({ name: 'First', rating_avg: 4 }),
buildPlace({ name: 'Second', rating_avg: 4 }),
buildPlace({ name: 'Unrated' }),
buildPlace({ name: 'Rated', rating_avg: 1 }),
];
render(<Host {...makeProps({ places })} />);
act(() => { S.setRatingSort(true); });
expect(names()).toEqual(['First', 'Second']);
act(() => { S.setRatingFilter(1); });
expect(names()).toEqual(['Rated']);
act(() => { S.setRatingFilter('all'); });
expect(names()).toEqual(['Unrated', 'Rated']);
});
it('FE-PLANNER-PSHOOK-014: search and category filter combine', () => {
@@ -147,7 +147,11 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter)
const [selectMode, setSelectMode] = useState(false)
// Star sort (#1435): list-only toggle, so it stays local (the map keeps its order).
const [ratingSort, setRatingSort] = useState(false)
// Minimum average stars, matching the collections filter (#1435): 'all', or a
// floor of 1..5 that unrated places fall through. It replaced a sort toggle,
// which put the best first but still left everything else on the list — no
// help at all when the point is to see only what the group actually rated.
const [ratingFilter, setRatingFilter] = useState<number | 'all'>('all')
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
@@ -179,6 +183,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
}
const [dayPickerPlace, setDayPickerPlace] = useState<Place | null>(null)
const [catDropOpen, setCatDropOpen] = useState(false)
const [starDropOpen, setStarDropOpen] = useState(false)
const [mobileShowDays, setMobileShowDays] = useState(false)
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
@@ -201,15 +206,11 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
}
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
return true
})
// Star sort (#1435): highest average first, unrated places at the end;
// ties keep the list's original order (stable sort).
if (ratingSort) {
return [...list].sort((a, b) => (b.rating_avg ?? -1) - (a.rating_avg ?? -1))
}
return list
}, [places, filter, categoryFilters, search, plannedIds, ratingSort])
}, [places, filter, categoryFilters, search, plannedIds, ratingFilter])
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
if (element) {
@@ -269,7 +270,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
listImportEnrich, setListImportEnrich, canEnrichImport,
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
search, setSearch, filter, setFilter, categoryFilters, setCategoryFilters,
ratingSort, setRatingSort,
ratingFilter, setRatingFilter,
starDropOpen, setStarDropOpen,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
categoryPickerOpen, setCategoryPickerOpen,
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { NavigationMenu } from './NavigationMenu'
import type { NavigationTarget } from '../Planner/placeNavigation'
// FE-COMP-NAVMENU-001 to FE-COMP-NAVMENU-007
const TARGETS: NavigationTarget[] = [
{ id: 'google', label: 'Google Maps', url: 'https://maps.example/g' },
{ id: 'waze', label: 'Waze', url: 'https://waze.example/w' },
]
/** A trigger at a chosen viewport position, so the flip can be provoked. */
function anchorAt(top: number, height = 30) {
const el = document.createElement('button')
document.body.appendChild(el)
el.getBoundingClientRect = () => ({
top, bottom: top + height, left: 40, right: 160, width: 120, height,
x: 40, y: top, toJSON: () => ({}),
}) as DOMRect
return el
}
/** jsdom measures everything as 0, so the menu needs a size to be placed. */
function withMenuHeight(height: number) {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
top: 0, bottom: height, left: 0, right: 200, width: 200, height,
x: 0, y: 0, toJSON: () => ({}),
} as DOMRect)
}
beforeEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
window.innerHeight = 800
window.innerWidth = 1200
})
describe('NavigationMenu', () => {
it('FE-COMP-NAVMENU-001: lists one entry per target', () => {
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={vi.fn()} />)
expect(screen.getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument()
})
it('FE-COMP-NAVMENU-002: opening a target closes the menu', () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
const onClose = vi.fn()
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={onClose} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Waze' }))
expect(open).toHaveBeenCalledWith('https://waze.example/w', '_blank', 'noopener,noreferrer')
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-COMP-NAVMENU-003: sits below the trigger when there is room', () => {
withMenuHeight(120)
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={vi.fn()} />)
// Trigger ends at 130, so the menu starts just under it.
expect(screen.getByRole('menu')).toHaveStyle({ top: '136px' })
})
it('FE-COMP-NAVMENU-004: flips above the trigger when the bottom edge is close', () => {
// Trigger ends at 780 with a viewport of 800: a 120px menu would be cut off.
withMenuHeight(120)
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(750)} onClose={vi.fn()} />)
expect(screen.getByRole('menu')).toHaveStyle({ top: '624px' })
})
it('FE-COMP-NAVMENU-005: never leaves the viewport, even when neither side fits', () => {
// Taller than the window: clamped to the top margin rather than negative.
withMenuHeight(900)
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(700)} onClose={vi.fn()} />)
expect(screen.getByRole('menu')).toHaveStyle({ top: '8px' })
})
it('FE-COMP-NAVMENU-006: Escape closes it', () => {
const onClose = vi.fn()
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={onClose} />)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-COMP-NAVMENU-007: renders nothing without targets', () => {
render(<NavigationMenu targets={[]} anchor={anchorAt(100)} onClose={vi.fn()} />)
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,131 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Navigation } from 'lucide-react'
import type { NavigationTarget } from '../Planner/placeNavigation'
import { openNavigationTarget } from '../Planner/placeNavigation'
interface NavigationMenuProps {
targets: NavigationTarget[]
/** The element the menu is anchored to; it is measured, never moved. */
anchor: HTMLElement | null
onClose: () => void
/** Heading above the list. Omitted on desktop, where the trigger says it. */
title?: string
}
const GAP = 6
const EDGE = 8
/**
* The map-app picker.
*
* Portalled rather than nested, because both call sites sit inside panels that
* clip their overflow — a menu rendered in place would be cut off at the panel
* edge. Being in the body means the position has to be computed, and computed
* after the menu has a size: opening near the bottom of the window flips it
* above the trigger instead of letting it run off the screen, and a menu wider
* than the space to its right is pulled back inside.
*/
export function NavigationMenu({ targets, anchor, onClose, title }: NavigationMenuProps) {
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
// Layout effect, not effect: the menu is placed before the browser paints, so
// it never appears at the wrong spot for a frame and jumps.
useLayoutEffect(() => {
const el = ref.current
if (!el || !anchor) return
const place = () => {
const a = anchor.getBoundingClientRect()
const m = el.getBoundingClientRect()
const below = window.innerHeight - a.bottom
const flip = below < m.height + GAP + EDGE && a.top > below
const top = flip ? Math.max(EDGE, a.top - m.height - GAP) : a.bottom + GAP
const left = Math.min(Math.max(EDGE, a.left), window.innerWidth - m.width - EDGE)
setPos({ top, left })
}
place()
window.addEventListener('resize', place)
window.addEventListener('scroll', place, true)
return () => {
window.removeEventListener('resize', place)
window.removeEventListener('scroll', place, true)
}
}, [anchor, targets.length])
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node) && !anchor?.contains(e.target as Node)) onClose()
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [anchor, onClose])
if (targets.length === 0) return null
return createPortal(
<div
ref={ref}
role="menu"
style={{
position: 'fixed',
top: pos?.top ?? -9999,
left: pos?.left ?? -9999,
// Hidden until measured, so the first paint is never in the wrong place.
visibility: pos ? 'visible' : 'hidden',
zIndex: 99999,
minWidth: 186,
maxHeight: `calc(100vh - ${EDGE * 2}px)`,
overflowY: 'auto',
padding: 5,
background: 'var(--bg-card)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid var(--border-primary)',
borderRadius: 12,
boxShadow: '0 10px 34px rgba(0,0,0,0.16)',
animation: 'trek-menu-enter 180ms cubic-bezier(0.23, 1, 0.32, 1)',
willChange: 'transform, opacity',
}}
>
{title && (
<div style={{
padding: '5px 10px 7px',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
fontWeight: 600,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'var(--text-faint)',
}}>
{title}
</div>
)}
{targets.map(target => (
<button
key={target.id}
role="menuitem"
onClick={() => { openNavigationTarget(target); onClose() }}
style={{
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
padding: '8px 10px', borderRadius: 8, border: 'none',
background: 'none', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500,
textAlign: 'left', color: 'var(--text-primary)',
transition: 'background 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'none' }}
>
<Navigation size={13} style={{ flexShrink: 0, color: 'var(--text-faint)' }} />
<span>{target.label}</span>
</button>
))}
</div>,
document.body,
)
}
@@ -18,7 +18,8 @@ import TrackColorPicker from '../../../../components/shared/TrackColorPicker'
import { resolveTrackColor, inheritedTrackColor } from '../../../../components/Map/trackColors'
import { avatarSrc } from '../../../../utils/avatarSrc'
import { openFile } from '../../../../utils/fileDownload'
import { getGoogleMapsUrlForPlace } from '../../../../components/Planner/placeGoogleMaps'
import { getNavigationTargets, openNavigationTarget } from '../../../../components/Planner/placeNavigation'
import { NavigationMenu } from '../../../../components/shared/NavigationMenu'
import type { Assignment, Day, TripMember } from '../../../../types'
import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi'
@@ -43,6 +44,8 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
const [dayPickerOpen, setDayPickerOpen] = useState(false)
const [participantPickerOpen, setParticipantPickerOpen] = useState(false)
const [uploading, setUploading] = useState(false)
const [navOpen, setNavOpen] = useState(false)
const navBtnRef = useRef<HTMLButtonElement>(null)
const [imgBusy, setImgBusy] = useState(false)
const [colorPickerOpen, setColorPickerOpen] = useState(false)
const fileInputRef = useRef<HTMLInputElement | null>(null)
@@ -199,9 +202,13 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
planner.setFitKey(k => k + 1)
}
// One app installed-and-offered means no picker: the tap opens it, which is
// what this button has always done. The sheet only appears when there is an
// actual choice to make.
const navTargets = getNavigationTargets(place)
const openDirections = () => {
const url = getGoogleMapsUrlForPlace(place)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
if (navTargets.length === 1) openNavigationTarget(navTargets[0])
else if (navTargets.length > 1) setNavOpen(true)
}
return (
@@ -496,10 +503,24 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
<Bookmark size={15} strokeWidth={2} />
</ActionCircle>
)}
{place.lat != null && place.lng != null && (
<ActionCircle onClick={openDirections} label={t('inspector.google')}>
<Navigation size={15} strokeWidth={2} />
</ActionCircle>
{navTargets.length > 0 && (
<>
<ActionCircle
ref={navBtnRef}
onClick={openDirections}
label={navTargets.length === 1 ? navTargets[0].label : t('inspector.navigation')}
>
<Navigation size={15} strokeWidth={2} />
</ActionCircle>
{navOpen && (
<NavigationMenu
targets={navTargets}
anchor={navBtnRef.current}
onClose={() => setNavOpen(false)}
title={t('inspector.openWith')}
/>
)}
</>
)}
{place.lat != null && place.lng != null && (
<ActionCircle onClick={showOnMap} label={t('mobileTrip.showOnMap')}>
@@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { ReactNode, Ref } from 'react'
import { X } from 'lucide-react'
import MIconBtn from '../../../components/MIconBtn'
import { formatTime } from '../../../../utils/formatters'
@@ -88,12 +88,15 @@ interface ActionCircleProps {
danger?: boolean
className?: string
children: ReactNode
/** For callers that anchor a popup to the button. React 19 passes it through. */
ref?: Ref<HTMLButtonElement>
}
/** 38px round action button of the inspector footer rows. */
export function ActionCircle({ onClick, label, primary = false, danger = false, className = '', children }: ActionCircleProps) {
export function ActionCircle({ onClick, label, primary = false, danger = false, className = '', children, ref }: ActionCircleProps) {
return (
<button
ref={ref}
type="button"
onClick={onClick}
aria-label={label}
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { ConfirmationCode, CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from './bookingsModel'
@@ -41,7 +41,14 @@ export default function MBookingsTab({ planner, shell }: MTabScreenProps) {
return (
<TabScroller>
{showTravelerFilter && (
<TravelerFilterRow members={planner.tripMembers} active={travelerFilter} onToggle={toggleTravelerFilter} label={t('reservations.travelers.label')} />
<TravelerFilterRow
members={planner.tripMembers}
active={travelerFilter}
onToggle={toggleTravelerFilter}
onClear={() => setTravelerFilter(new Set())}
label={t('reservations.travelers.label')}
allLabel={t('common.all')}
/>
)}
{sections.length === 0 ? (
<div className="flex min-h-full flex-1 flex-col items-center justify-center px-8 py-10 text-center">
@@ -52,7 +59,7 @@ export default function MBookingsTab({ planner, shell }: MTabScreenProps) {
<div key={section.id}>
<SectionHeader
label={section.label}
count={<CountPill>{section.rows.length}</CountPill>}
count={section.rows.length}
open={!collapsed[section.id]}
onToggle={() => toggle(section.id)}
/>
@@ -9,7 +9,7 @@ import MSheet from '../../../components/MSheet'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import MConfirmSheet from '../../settings/MConfirmSheet'
import type { TripPlanner } from '../MTripShell'
import { CountPill, SectionHeader, TabScroller } from './tabChrome'
import { SectionHeader, TabScroller } from './tabChrome'
import {
formatPollCountdown,
hasUserVoted,
@@ -191,7 +191,7 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) {
{active.length > 0 && (
<SectionHeader
label={t('collab.polls.closedSection')}
count={<CountPill>{closed.length}</CountPill>}
count={closed.length}
open={!collapsedClosed}
onToggle={() => setCollapsedClosed(v => !v)}
/>
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { ConfirmationCode, CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import {
TRANSPORT_TYPE_COLOR,
@@ -46,7 +46,14 @@ export default function MTransportsTab({ planner, shell }: MTabScreenProps) {
return (
<TabScroller>
{showTravelerFilter && (
<TravelerFilterRow members={planner.tripMembers} active={travelerFilter} onToggle={toggleTravelerFilter} label={t('reservations.travelers.label')} />
<TravelerFilterRow
members={planner.tripMembers}
active={travelerFilter}
onToggle={toggleTravelerFilter}
onClear={() => setTravelerFilter(new Set())}
label={t('reservations.travelers.label')}
allLabel={t('common.all')}
/>
)}
{sections.length === 0 ? (
<div className="flex min-h-full flex-col items-center justify-center px-8 py-10 text-center">
@@ -57,7 +64,7 @@ export default function MTransportsTab({ planner, shell }: MTabScreenProps) {
<div key={section.id}>
<SectionHeader
label={section.label}
count={<CountPill>{section.rows.length}</CountPill>}
count={section.rows.length}
open={!collapsed[section.id]}
onToggle={() => toggle(section.id)}
/>
@@ -158,31 +158,65 @@ export function TravelerAvatars({ travelers, label }: {
)
}
/** Avatar toggle row to filter the list by assigned traveler (#1517/#1557). */
export function TravelerFilterRow({ members, active, onToggle, label }: {
/**
* Avatar toggle row to filter the list by assigned traveler (#1517/#1557).
*
* The avatars used to sit on their own between the toolbar and the first
* section, which read as decoration rather than as a control: nothing said what
* they were, and with a filter on there was no way back other than tapping the
* same avatar again. It is a labelled bar now, closed off by the same divider
* the rows below use, with an explicit way to clear the filter.
*
* Its horizontal padding follows SectionHeader rather than the sheet's usual 18,
* so the label starts on the same line as the section titles right below it.
*/
export function TravelerFilterRow({ members, active, onToggle, onClear, label, allLabel }: {
members: { id: number; username: string; avatar_url?: string | null }[]
active: Set<number>
onToggle: (id: number) => void
onClear?: () => void
label: string
/** Resets the filter. Only rendered while one is active. */
allLabel?: string
}) {
const filtering = active.size > 0
return (
<div className="flex flex-wrap items-center gap-1.5 px-[18px] pb-2 pt-1" aria-label={label}>
{members.map(m => {
const on = active.has(m.id)
return (
<button
key={m.id}
type="button"
onClick={() => onToggle(m.id)}
title={m.username}
className={`flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 bg-[color:var(--m-ic)] text-[0.625rem] font-bold text-m-muted transition-opacity ${on ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'} ${on || active.size === 0 ? 'opacity-100' : 'opacity-40'}`}
>
{m.avatar_url
? <img src={m.avatar_url} className="h-full w-full object-cover" alt="" />
: m.username?.[0]?.toUpperCase()}
</button>
)
})}
<div
className="mb-[2px] flex items-center gap-[10px] border-b border-[color:var(--m-rowbr)] px-[2px] pb-[9px] pt-[2px]"
role="group"
aria-label={label}
>
<span className="flex-none font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
{label}
</span>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{members.map(m => {
const on = active.has(m.id)
return (
<button
key={m.id}
type="button"
onClick={() => onToggle(m.id)}
title={m.username}
aria-pressed={on}
className={`flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 bg-[color:var(--m-ic)] text-[0.625rem] font-bold text-m-muted transition-opacity ${on ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'} ${on || !filtering ? 'opacity-100' : 'opacity-40'}`}
>
{m.avatar_url
? <img src={m.avatar_url} className="h-full w-full object-cover" alt="" />
: m.username?.[0]?.toUpperCase()}
</button>
)
})}
</div>
{filtering && allLabel && onClear && (
<button
type="button"
onClick={onClear}
className="flex-none rounded-full border border-[color:var(--m-rowbr)] px-[9px] py-[3px] font-geist text-[0.625rem] font-bold uppercase tracking-[.06em] text-m-muted"
>
{allLabel}
</button>
)}
</div>
)
}
+19 -1
View File
@@ -69,6 +69,7 @@ function makeHook(overrides: Record<string, unknown> = {}) {
places: [{ id: 10 }],
visiblePlaces: [{ id: 10 }],
mappable: [{ id: 10 }],
hasMappable: true,
members: [],
incomingInvites: [],
counts: { all: 1, idea: 0, want: 1, visited: 0 },
@@ -124,8 +125,25 @@ describe('CollectionsPage — Add place button reachability (#1485)', () => {
})
it('shows the Add button on an empty collection', () => {
mockUseCollections.mockReturnValue(makeHook({ places: [], visiblePlaces: [], mappable: [] }))
mockUseCollections.mockReturnValue(makeHook({ places: [], visiblePlaces: [], mappable: [], hasMappable: false }))
render(<CollectionsPage />)
expect(screen.getAllByRole('button', { name: 'collections.addPlace' })).toHaveLength(1)
})
})
describe('CollectionsPage — the map survives a filter that matches nothing', () => {
it('keeps the map when a label filter empties the list', () => {
// Clicking a label with no places behind it left visiblePlaces empty, and
// the layout hung off exactly that: the map was torn out and the page
// reflowed to full width. A filter should empty the map, not remove it.
mockUseCollections.mockReturnValue(makeHook({ visiblePlaces: [], mappable: [], hasMappable: true }))
render(<CollectionsPage />)
expect(screen.getByTestId('map')).toBeInTheDocument()
})
it('still hides the map for a list with nothing mappable in it at all', () => {
mockUseCollections.mockReturnValue(makeHook({ mappable: [], hasMappable: false }))
render(<CollectionsPage />)
expect(screen.queryByTestId('map')).toBeNull()
})
})
+10 -5
View File
@@ -59,8 +59,8 @@ function CollectionsPageDesktop(): React.ReactElement {
// + detail come into view alongside the map.
const onMapSelect = (id: number) => { openPlace(id); if (c.view === 'map') c.setView('list') }
const desktopSplit = c.isWide && mappable.length > 0
const mapShown = mappable.length > 0 && (c.view === 'map' || c.isWide)
const desktopSplit = c.isWide && c.hasMappable
const mapShown = c.hasMappable && (c.view === 'map' || c.isWide)
const mapOverlay = c.isWide && mapShown // the map carries the toggle + search
const canAddPlace = typeof c.activeId === 'number' && c.canEdit // a real list you can edit
@@ -79,6 +79,11 @@ function CollectionsPageDesktop(): React.ReactElement {
)
const mapPanel = (overlay: boolean) => (
<CollectionMapPanel
labelOptions={isRealList ? c.labelOptions : []}
labelFilter={c.labelFilter}
onLabelFilter={isRealList ? c.setLabelFilter : undefined}
canManageLabels={canManageLabels}
onManageLabels={() => c.setShowLabelManager(true)}
places={mappable}
selectedPlaceId={c.selectedPlaceId}
onSelect={onMapSelect}
@@ -109,7 +114,7 @@ function CollectionsPageDesktop(): React.ReactElement {
onSortMode={c.setSortMode}
canAddPlace={canAddPlace}
onAddPlace={() => c.setShowAddPlace(true)}
showLabels={isRealList}
showLabels={isRealList && !mapShown}
labelOptions={c.labelOptions}
labelFilter={c.labelFilter}
onLabelFilter={c.setLabelFilter}
@@ -151,7 +156,7 @@ function CollectionsPageDesktop(): React.ReactElement {
<div className="col-split-map">{mapPanel(true)}</div>
</div>
)
} else if (c.view === 'map' && mappable.length > 0) {
} else if (c.view === 'map' && c.hasMappable) {
body = <div className="col-mapwrap">{mapPanel(false)}</div>
} else {
body = listColumn
@@ -212,7 +217,7 @@ function CollectionsPageDesktop(): React.ReactElement {
<button type="button" className="col-rail-toggle" onClick={() => c.setMobileRailOpen(true)}>
<Bookmark size={15} /> {t('collections.title')}
</button>
{!c.isWide && mappable.length > 0 && (
{!c.isWide && c.hasMappable && (
<div className="col-viewseg" role="group" aria-label={t('collections.title')}>
<button type="button" aria-pressed={c.view === 'list'} onClick={() => c.setView('list')} aria-label={t('collections.view.list')} title={t('collections.view.list')} className={c.view === 'list' ? 'on' : ''}>
<ListIcon size={16} />
@@ -59,9 +59,11 @@ vi.mock('../components/Collections/CollectionFilterBar', () => ({
}))
vi.mock('../components/Collections/CollectionMapPanel', () => ({
default: (p: { overlay: boolean; places: { id: number }[]; onSelect: (id: number) => void; onDeselect: () => void; onToggleView: () => void; onSearch: (v: string) => void }) => (
default: (p: { overlay: boolean; places: { id: number }[]; onSelect: (id: number) => void; onDeselect: () => void; onToggleView: () => void; onSearch: (v: string) => void; onLabelFilter?: (ids: number[]) => void; onManageLabels?: () => void }) => (
<div data-testid={p.overlay ? 'map-overlay' : 'map-plain'}>
<span data-testid="map-count">{p.places.length}</span>
<span data-testid="map-has-labels">{String(p.onLabelFilter != null)}</span>
{p.onManageLabels && <button type="button" onClick={p.onManageLabels}>map-labels</button>}
<button type="button" onClick={() => p.onSelect(10)}>map-select</button>
<button type="button" onClick={p.onDeselect}>map-deselect</button>
<button type="button" onClick={p.onToggleView}>map-toggle</button>
@@ -206,6 +208,7 @@ function makeHook(overrides: Hook = {}): Hook {
places: [{ id: 10 }],
visiblePlaces: [{ id: 10 }],
mappable: [{ id: 10 }],
hasMappable: true,
members: [],
incomingInvites: [],
counts: { all: 1, idea: 0, want: 1, visited: 0 },
@@ -340,7 +343,7 @@ describe('CollectionsPage — shell', () => {
})
it('FE-PAGE-COLLPAGE-007: shows a spinner while the first places load', () => {
renderPage({ places: [], visiblePlaces: [], mappable: [], placesLoading: true })
renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false, placesLoading: true })
expect(document.querySelector('.col-spinner')).not.toBeNull()
expect(screen.queryByTestId('list')).toBeNull()
})
@@ -348,7 +351,7 @@ describe('CollectionsPage — shell', () => {
describe('CollectionsPage — body branches', () => {
it('FE-PAGE-COLLPAGE-008: an empty list shows the empty state plus the add CTA', () => {
const hook = renderPage({ places: [], visiblePlaces: [], mappable: [] })
const hook = renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false })
expect(screen.getByTestId('empty-collections')).toHaveTextContent('collections.empty.title')
fireEvent.click(screen.getByRole('button', { name: /collections.addPlace/ }))
@@ -356,7 +359,7 @@ describe('CollectionsPage — body branches', () => {
})
it('FE-PAGE-COLLPAGE-009: a viewer on an empty list gets no add CTA', () => {
renderPage({ places: [], visiblePlaces: [], mappable: [], canEdit: false })
renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false, canEdit: false })
expect(screen.getByTestId('empty-collections')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /collections.addPlace/ })).toBeNull()
})
@@ -384,13 +387,13 @@ describe('CollectionsPage — body branches', () => {
})
it('FE-PAGE-COLLPAGE-013: places without coordinates keep the list column even on a wide layout', () => {
renderPage({ mappable: [] })
renderPage({ mappable: [], hasMappable: false })
expect(screen.getByTestId('list')).toBeInTheDocument()
expect(screen.queryByTestId('map-overlay')).toBeNull()
})
it('FE-PAGE-COLLPAGE-014: a filter that matches nothing keeps the filter bar and shows the no-match state', () => {
renderPage({ visiblePlaces: [], mappable: [] })
renderPage({ visiblePlaces: [], mappable: [], hasMappable: false })
expect(screen.getByTestId('filterbar')).toBeInTheDocument()
expect(screen.getByTestId('empty-search')).toHaveTextContent('collections.empty.noMatchTitle')
expect(screen.queryByTestId('list')).toBeNull()
@@ -555,20 +558,32 @@ describe('CollectionsPage — child wiring', () => {
expect(hook.setSelectedPlaceId).not.toHaveBeenCalled()
})
it('FE-PAGE-COLLPAGE-030: the filter bar drives add-place, label management and select mode', () => {
it('FE-PAGE-COLLPAGE-030: the filter bar drives add-place and select mode, the map takes the labels', () => {
const hook = renderPage()
expect(screen.getByTestId('filter-flags')).toHaveTextContent('true|true|true')
// Labels ride in the map's top bar while a map is on screen, so the filter
// row is told not to draw them a second time.
expect(screen.getByTestId('filter-flags')).toHaveTextContent('false|true|true')
expect(screen.getByTestId('map-has-labels')).toHaveTextContent('true')
fireEvent.click(screen.getByText('filter-add'))
expect(hook.setShowAddPlace).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByText('filter-labels'))
fireEvent.click(screen.getByText('map-labels'))
expect(hook.setShowLabelManager).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByText('filter-select'))
expect(hook.setSelectMode).toHaveBeenCalledWith(true)
})
it('FE-PAGE-COLLPAGE-030b: without a map the filter row keeps the labels, so they stay reachable', () => {
const hook = renderPage({ mappable: [], hasMappable: false })
expect(screen.getByTestId('filter-flags')).toHaveTextContent('true|true|true')
expect(screen.queryByTestId('map-overlay')).toBeNull()
fireEvent.click(screen.getByText('filter-labels'))
expect(hook.setShowLabelManager).toHaveBeenCalledWith(true)
})
it('FE-PAGE-COLLPAGE-031: the All-saved union has no labels and no add button in the filter row', () => {
renderPage({ isAllSaved: true, activeId: 'all', activeCollection: null })
expect(screen.getByTestId('filter-flags')).toHaveTextContent('false|false|true')
@@ -177,6 +177,11 @@ export function useCollections() {
// Stable reference so the map doesn't tear down + rebuild every marker on each
// unrelated re-render (which would swallow marker clicks mid-rebuild).
const mappable = useMemo(() => mappablePlaces(visiblePlaces), [visiblePlaces])
// Whether this list has anything mappable AT ALL, filters aside. The layout
// hangs off this rather than off `mappable`: a label with no places left the
// filtered set empty, which tore the map out and reflowed the page to full
// width. The filter should empty the map, not remove it.
const hasMappable = useMemo(() => mappablePlaces(places).length > 0, [places])
const counts = useMemo(() => statusCounts(places), [places])
// ── Handlers ────────────────────────────────────────────────────────
@@ -385,7 +390,7 @@ export function useCollections() {
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
myRole, canEdit, canDelete,
canShare, shareMemberCount,
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
activeId, places, visiblePlaces, mappable, hasMappable, members, incomingInvites, counts,
view, statusFilter, categoryFilter, categoryOptions, ratingFilter, sortMode, search, selectedPlaceId, selectMode, selectedIds,
labels, labelFilter, labelOptions,
loading, placesLoading,
+54 -10
View File
@@ -274,9 +274,31 @@
.trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); }
.trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); }
.trek-dash .col-labelchip-dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; background: var(--label, var(--accent)); }
.trek-dash .col-labelfilter { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-labelchip:hover { background: var(--surface-2); color: var(--ink); }
/* Labels are their own axis of filtering, so they read as one tray rather than
as more loose buttons: a recessed strip, led by a tag glyph, holding chips
that share the filter buttons' radius and type. The chips carry no border of
their own — a frame inside a frame is what made this look like a pile. */
.trek-dash .col-labelfilter {
display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px;
min-height: var(--col-ctl-h); padding: 3px 6px 3px 9px; border-radius: 11px;
background: color-mix(in oklch, var(--surface-2) 70%, transparent);
border: 1px solid var(--line-2);
}
.trek-dash .col-labelfilter-lead { color: var(--ink-3); flex-shrink: 0; margin-right: 2px; }
/* On the map the tray joins the floating controls, so it takes their glass,
their radius and their shadow instead of the row's flat surface. */
.trek-dash .col-labelfilter.on-map {
pointer-events: auto; max-width: min(52ch, 100%); flex-wrap: nowrap; overflow-x: auto;
border-radius: 11px; border-color: var(--glass-border); box-shadow: var(--sh-md);
background: color-mix(in oklch, var(--surface) 92%, transparent);
backdrop-filter: blur(8px) saturate(1.2); -webkit-backdrop-filter: blur(8px) saturate(1.2);
scrollbar-width: none;
}
.trek-dash .col-labelfilter.on-map::-webkit-scrollbar { display: none; }
.trek-dash .col-labelfilter.on-map .col-labelchip { flex-shrink: 0; }
.trek-dash .col-labelfilter.on-map .col-labelchip:hover { background: var(--surface-2); }
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 5px 9px; border-radius: 8px; font-size: 12.5px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: transparent; border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-labelchip:hover { background: var(--surface); color: var(--ink); }
.trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; }
.trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); }
.trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); }
@@ -284,14 +306,35 @@
.trek-dash .col-detail-labels { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 10px; }
/* ----------------- filter bar (status + category dropdowns) ----------------- */
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
/* One control height for the whole page: the filter row and the buttons floating
over the map are read as a single band when the page is scrolled to the top,
so they share a height and a top offset instead of each having their own. */
.trek-dash { --col-ctl-h: 38px; }
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; }
/* Beside the map, the row starts where the map's own top bar does. */
.trek-dash .col-split-list .col-filterbar { margin-top: 12px; }
.trek-dash .col-filter { position: relative; }
/* Icon-only add-place button: square it off so it matches the dropdowns' height. */
.trek-dash .col-filter-add { padding: 7px; }
/* Square icon-only buttons: as wide as they are tall, so they sit in the row
without being the one element that is a few pixels off. */
.trek-dash .col-filter-add,
.trek-dash .col-filter-icon { width: var(--col-ctl-h); padding: 0; justify-content: center; }
/* Manage/add labels sits inside the label tray, so it stays quiet until hovered. */
.trek-dash .col-filter-addlabel {
display: inline-flex; align-items: center; justify-content: center;
padding: 5px; border-radius: 8px; color: var(--ink-3);
background: transparent; border: 1px solid transparent;
transition: background .12s, color .12s;
}
.trek-dash .col-filter-addlabel:hover { background: var(--surface); color: var(--ink); }
.trek-dash .col-filter-select { margin-left: auto; }
.trek-dash .col-filter-select.open { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.trek-dash .col-filter-select.open:hover { background: var(--accent); color: var(--accent-text); }
.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
/* `.on`, not `.open`: on a dropdown "open" means the panel is down and the text
stays dark, and that rule sits later in this file, so sharing the name left
the active select button drawing a dark icon on its dark accent fill. The
other toggles in this page (col-map-btn, col-iconbtn) already use `.on`. */
.trek-dash .col-filter-select.on { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.trek-dash .col-filter-select.on:hover { background: var(--accent); color: var(--accent-text); }
.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; height: var(--col-ctl-h); padding: 0 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-filter-btn:hover { background: var(--surface-2); color: var(--ink); }
.trek-dash .col-filter-btn.open { border-color: var(--accent); color: var(--ink); }
.trek-dash .col-filter-btn .col-filter-chev { color: var(--ink-3); transition: transform .15s; }
@@ -461,9 +504,10 @@
.trek-dash .col-search { flex: 1; }
.trek-dash .col-mapwrap { height: calc(100dvh - 260px); min-height: 320px; }
/* bigger touch targets on phones (visuals unchanged on desktop) */
/* bigger touch targets on phones (visuals unchanged on desktop). Raising the
shared height keeps the square icon buttons square. */
.trek-dash { --col-ctl-h: 40px; }
.trek-dash .col-viewseg button { width: 44px; height: 38px; }
.trek-dash .col-filter-btn { min-height: 40px; }
.trek-dash .col-filter-opt { min-height: 40px; }
.trek-dash .col-selbar-btn { min-height: 40px; }
.trek-dash .col-detail-close { width: 40px; height: 40px; }
@@ -368,9 +368,14 @@ describe('MPlaceSheet', () => {
const shell = makeShell({ trTab: 'dateien', view: 'plan' })
const { planner } = renderSheet(makePlanner(), shell)
fireEvent.click(screen.getByRole('button', { name: 'Google Maps' }))
// A place with coordinates reaches more than one map app, so the circle
// opens the picker instead of guessing which one was meant.
fireEvent.click(screen.getByRole('button', { name: 'Navigation' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Google Maps' }))
// Name and address rather than bare coordinates (#1278): the place has both,
// so Google opens the entry instead of an unlabelled pin.
expect(window.open).toHaveBeenCalledWith(
'https://www.google.com/maps/search/?api=1&query=48.2038,16.3616', '_blank', 'noopener,noreferrer',
'https://www.google.com/maps/search/?api=1&query=Kunsthistorisches%20Museum%2C%20Maria-Theresien-Platz', '_blank', 'noopener,noreferrer',
)
fireEvent.click(screen.getByRole('button', { name: 'Open Website' }))
@@ -405,7 +410,7 @@ describe('MPlaceSheet', () => {
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Save to Collection' })).not.toBeInTheDocument()
// Read-only members still get the navigation actions.
expect(screen.getByRole('button', { name: 'Google Maps' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument()
})
it('FE-MOB-PLSH-026: collapses the track colour picker again when the sheet closes', () => {
@@ -148,4 +148,54 @@ describe('tabChrome', () => {
expect(screen.getByTitle('Ada').className).toContain('opacity-100')
expect(screen.getByTitle('Bob').className).toContain('opacity-100')
})
it('FE-MOB-TABCHR-015: TravelerFilterRow names itself, so the avatars are not left unexplained', () => {
render(
<TravelerFilterRow
members={[{ id: 1, username: 'Ada' }]}
active={new Set()}
onToggle={vi.fn()}
label="Travelers"
/>,
)
expect(screen.getByRole('group', { name: 'Travelers' })).toBeInTheDocument()
expect(screen.getByText('Travelers')).toBeInTheDocument()
})
it('FE-MOB-TABCHR-016: the way out of a filter only appears once one is set', () => {
const onClear = vi.fn()
const { rerender } = render(
<TravelerFilterRow
members={[{ id: 1, username: 'Ada' }]}
active={new Set()}
onToggle={vi.fn()}
onClear={onClear}
label="Travelers"
allLabel="All"
/>,
)
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument()
rerender(
<TravelerFilterRow
members={[{ id: 1, username: 'Ada' }]}
active={new Set([1])}
onToggle={vi.fn()}
onClear={onClear}
label="Travelers"
allLabel="All"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'All' }))
expect(onClear).toHaveBeenCalledTimes(1)
})
it('FE-MOB-TABCHR-017: SectionHeader wraps its count exactly once', () => {
// The callers used to pass a <CountPill> into a prop that wraps one itself,
// which put a pill inside a pill and showed two stacked backgrounds.
render(<SectionHeader label="Pending" count={2} open onToggle={vi.fn()} />)
const pill = screen.getByText('2')
expect(pill.querySelector('span')).toBeNull()
expect(pill.parentElement?.tagName).toBe('BUTTON')
})
})
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'حجز مؤكد',
'inspector.pendingRes': 'حجز قيد الانتظار',
'inspector.google': 'Google Maps',
'inspector.navigation': 'التنقل',
'inspector.openWith': 'الفتح باستخدام',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'فتح الموقع الإلكتروني',
'inspector.saveToCollection': 'حفظ في مجموعة',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'غير مخطط',
'places.planned': 'مخطط لها',
'places.filterTracks': 'المسارات',
'places.sortByRating': 'الترتيب حسب التقييم',
'places.filterByRating': 'تصفية حسب التقييم',
'places.yourRating': 'تقييمك',
'places.notRated': 'لم يُقيَّم بعد',
'places.search': 'ابحث عن أماكن...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Reserva confirmada',
'inspector.pendingRes': 'Reserva pendente',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navegação',
'inspector.openWith': 'Abrir com',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.saveToCollection': 'Salvar na Coleção',
'inspector.savedToCollection': 'Salvo',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Não planejados',
'places.planned': 'Planejados',
'places.filterTracks': 'Trilhas',
'places.sortByRating': 'Ordenar por avaliação',
'places.filterByRating': 'Filtrar por avaliação',
'places.yourRating': 'Sua avaliação',
'places.notRated': 'Ainda sem avaliações',
'places.search': 'Buscar lugares...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Reserva confirmada',
'inspector.pendingRes': 'Reserva pendent',
'inspector.google': 'Obre a Google Maps',
'inspector.navigation': 'Navegació',
'inspector.openWith': 'Obre amb',
'inspector.website': 'Obre el lloc web',
'inspector.addRes': 'Reserva',
'inspector.editRes': 'Edita la reserva',
+1 -1
View File
@@ -43,7 +43,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Sense planificar',
'places.planned': 'Planificats',
'places.filterTracks': 'Rutes',
'places.sortByRating': 'Ordena per valoració',
'places.filterByRating': 'Filtra per valoració',
'places.yourRating': 'La teva valoració',
'places.notRated': 'Encara sense valoració',
'places.search': 'Cerca llocs...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Potvrzená rezervace',
'inspector.pendingRes': 'Čekající rezervace',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigace',
'inspector.openWith': 'Otevřít v',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Otevřít webové stránky',
'inspector.saveToCollection': 'Uložit do sbírky',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Nezařazené',
'places.planned': 'Naplánované',
'places.filterTracks': 'Trasy',
'places.sortByRating': 'Seřadit podle hodnocení',
'places.filterByRating': 'Filtrovat podle hodnocení',
'places.yourRating': 'Tvé hodnocení',
'places.notRated': 'Zatím bez hodnocení',
'places.search': 'Hledat místa...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Bestätigte Reservierung',
'inspector.pendingRes': 'Ausstehende Reservierung',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigation',
'inspector.openWith': 'Öffnen mit',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Webseite öffnen',
'inspector.saveToCollection': 'In Sammlung speichern',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Ungeplant',
'places.planned': 'Geplant',
'places.filterTracks': 'Tracks',
'places.sortByRating': 'Nach Bewertung sortieren',
'places.filterByRating': 'Nach Bewertung filtern',
'places.yourRating': 'Deine Bewertung',
'places.notRated': 'Noch nicht bewertet',
'places.search': 'Orte suchen...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Confirmed Reservation',
'inspector.pendingRes': 'Pending Reservation',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigation',
'inspector.openWith': 'Open with',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Open Website',
'inspector.saveToCollection': 'Save to Collection',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Unplanned',
'places.planned': 'Planned',
'places.filterTracks': 'Tracks',
'places.sortByRating': 'Sort by rating',
'places.filterByRating': 'Filter by rating',
'places.yourRating': 'Your rating',
'places.notRated': 'Not rated yet',
'places.search': 'Search places...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Reserva confirmada',
'inspector.pendingRes': 'Reserva pendiente',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navegación',
'inspector.openWith': 'Abrir con',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Abrir la web',
'inspector.saveToCollection': 'Guardar en colección',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Sin planificar',
'places.planned': 'Planificados',
'places.filterTracks': 'Rutas',
'places.sortByRating': 'Ordenar por valoración',
'places.filterByRating': 'Filtrar por valoración',
'places.yourRating': 'Tu valoración',
'places.notRated': 'Sin valorar todavía',
'places.search': 'Buscar lugares...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Réservation confirmée',
'inspector.pendingRes': 'Réservation en attente',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigation',
'inspector.openWith': 'Ouvrir avec',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Ouvrir le site web',
'inspector.saveToCollection': 'Enregistrer dans une collection',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Non planifiés',
'places.planned': 'Planifiés',
'places.filterTracks': 'Traces',
'places.sortByRating': 'Trier par note',
'places.filterByRating': 'Filtrer par note',
'places.yourRating': 'Ta note',
'places.notRated': 'Pas encore noté',
'places.search': 'Rechercher des lieux…',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Επιβεβαιωμένη Κράτηση',
'inspector.pendingRes': 'Εκκρεμής Κράτηση',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Πλοήγηση',
'inspector.openWith': 'Άνοιγμα με',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.saveToCollection': 'Αποθήκευση στη Συλλογή',
'inspector.savedToCollection': 'Αποθηκεύτηκε',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Μη προγραμματισμένα',
'places.planned': 'Προγραμματισμένα',
'places.filterTracks': 'Ίχνη',
'places.sortByRating': 'Ταξινόμηση κατά βαθμολογία',
'places.filterByRating': 'Φιλτράρισμα κατά βαθμολογία',
'places.yourRating': 'Η βαθμολογία σου',
'places.notRated': 'Χωρίς βαθμολογία ακόμη',
'places.search': 'Αναζήτηση μερών...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Megerősített foglalás',
'inspector.pendingRes': 'Függőben lévő foglalás',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigáció',
'inspector.openWith': 'Megnyitás ezzel',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Weboldal megnyitása',
'inspector.saveToCollection': 'Mentés gyűjteménybe',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Nem tervezett',
'places.planned': 'Tervezett',
'places.filterTracks': 'Nyomvonalak',
'places.sortByRating': 'Rendezés értékelés szerint',
'places.filterByRating': 'Szűrés értékelés szerint',
'places.yourRating': 'Az értékelésed',
'places.notRated': 'Még nincs értékelve',
'places.search': 'Helyek keresése...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Reservasi Dikonfirmasi',
'inspector.pendingRes': 'Reservasi Menunggu',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigasi',
'inspector.openWith': 'Buka dengan',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.saveToCollection': 'Simpan ke Koleksi',
'inspector.savedToCollection': 'Tersimpan',
+1 -1
View File
@@ -44,7 +44,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Belum direncanakan',
'places.planned': 'Direncanakan',
'places.filterTracks': 'Trek',
'places.sortByRating': 'Urutkan berdasarkan rating',
'places.filterByRating': 'Saring menurut penilaian',
'places.yourRating': 'Rating kamu',
'places.notRated': 'Belum dinilai',
'places.search': 'Cari tempat...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Prenotazione confermata',
'inspector.pendingRes': 'Prenotazione in attesa',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigazione',
'inspector.openWith': 'Apri con',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Apri sito web',
'inspector.saveToCollection': 'Salva nella raccolta',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Non pianificati',
'places.planned': 'Pianificati',
'places.filterTracks': 'Tracce',
'places.sortByRating': 'Ordina per valutazione',
'places.filterByRating': 'Filtra per valutazione',
'places.yourRating': 'La tua valutazione',
'places.notRated': 'Non ancora valutato',
'places.search': 'Cerca luoghi...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': '確定済み予約',
'inspector.pendingRes': '保留中の予約',
'inspector.google': 'Google Maps',
'inspector.navigation': 'ナビゲーション',
'inspector.openWith': 'アプリで開く',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Webサイトを開く',
'inspector.saveToCollection': 'コレクションに保存',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': '未計画',
'places.planned': '計画済み',
'places.filterTracks': 'トラック',
'places.sortByRating': '評価順に並べ替え',
'places.filterByRating': '評価で絞り込む',
'places.yourRating': 'あなたの評価',
'places.notRated': '未評価',
'places.search': '場所を検索…',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': '확정된 예약',
'inspector.pendingRes': '대기 중인 예약',
'inspector.google': 'Google Maps',
'inspector.navigation': '내비게이션',
'inspector.openWith': '다음으로 열기',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': '웹사이트 열기',
'inspector.saveToCollection': '컬렉션에 저장',
+1 -1
View File
@@ -44,7 +44,7 @@ const places: TranslationStrings = {
'places.unplanned': '미계획',
'places.planned': '계획됨',
'places.filterTracks': '트랙',
'places.sortByRating': '평점순 정렬',
'places.filterByRating': '평점으로 필터링',
'places.yourRating': '내 평점',
'places.notRated': '아직 평점 없음',
'places.search': '장소 검색...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Bevestigde reservering',
'inspector.pendingRes': 'Reservering in behandeling',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigatie',
'inspector.openWith': 'Openen met',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Website openen',
'inspector.saveToCollection': 'In collectie opslaan',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Ongepland',
'places.planned': 'Gepland',
'places.filterTracks': 'Tracks',
'places.sortByRating': 'Sorteren op beoordeling',
'places.filterByRating': 'Filteren op beoordeling',
'places.yourRating': 'Jouw beoordeling',
'places.notRated': 'Nog niet beoordeeld',
'places.search': 'Plaatsen zoeken...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Potwierdzona rezerwacja',
'inspector.pendingRes': 'Oczekująca rezerwacja',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Nawigacja',
'inspector.openWith': 'Otwórz w',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Otwórz stronę internetową',
'inspector.saveToCollection': 'Zapisz w kolekcji',
+1 -1
View File
@@ -36,7 +36,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Niezaplanowane',
'places.planned': 'Zaplanowane',
'places.filterTracks': 'Trasy',
'places.sortByRating': 'Sortuj według oceny',
'places.filterByRating': 'Filtruj według oceny',
'places.yourRating': 'Twoja ocena',
'places.notRated': 'Jeszcze nie oceniono',
'places.search': 'Szukaj miejsc...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Подтверждённое бронирование',
'inspector.pendingRes': 'Ожидающее бронирование',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Навигация',
'inspector.openWith': 'Открыть в',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Открыть сайт',
'inspector.saveToCollection': 'Сохранить в коллекцию',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Незапланированные',
'places.planned': 'Запланированные',
'places.filterTracks': 'Треки',
'places.sortByRating': 'Сортировать по рейтингу',
'places.filterByRating': 'Фильтр по оценке',
'places.yourRating': 'Твоя оценка',
'places.notRated': 'Ещё нет оценок',
'places.search': 'Поиск мест...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Bekräftad bokning',
'inspector.pendingRes': 'Pendlande bokning',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navigation',
'inspector.openWith': 'Öppna med',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Öppna hemsida',
'inspector.saveToCollection': 'Spara i samling',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Oplanerat',
'places.planned': 'Planerat',
'places.filterTracks': 'Spår',
'places.sortByRating': 'Sortera efter betyg',
'places.filterByRating': 'Filtrera efter betyg',
'places.yourRating': 'Ditt betyg',
'places.notRated': 'Inte betygsatt ännu',
'places.search': 'Sök efter platser...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Onaylanmış Rezervasyon',
'inspector.pendingRes': 'Bekleyen Rezervasyon',
'inspector.google': "Google Haritalar'da aç",
'inspector.navigation': 'Navigasyon',
'inspector.openWith': 'Şununla aç',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.saveToCollection': 'Koleksiyona kaydet',
'inspector.savedToCollection': 'Kaydedildi',
+1 -1
View File
@@ -47,7 +47,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Planlanmamış',
'places.planned': 'Planlanmış',
'places.filterTracks': 'Parçalar',
'places.sortByRating': 'Puana göre sırala',
'places.filterByRating': 'Puana göre filtrele',
'places.yourRating': 'Senin puanın',
'places.notRated': 'Henüz puanlanmadı',
'places.search': 'Yer ara...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Підтверджене бронювання',
'inspector.pendingRes': 'Очікуване бронювання',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Навігація',
'inspector.openWith': 'Відкрити в',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'Відкрити сайт',
'inspector.saveToCollection': 'Зберегти в колекцію',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Незаплановані',
'places.planned': 'Заплановані',
'places.filterTracks': 'Треки',
'places.sortByRating': 'Сортувати за рейтингом',
'places.filterByRating': 'Фільтр за оцінкою',
'places.yourRating': 'Твоя оцінка',
'places.notRated': 'Ще не оцінено',
'places.search': 'Пошук місць...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Đã xác nhận đặt chỗ',
'inspector.pendingRes': 'Đang chờ đặt chỗ',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Điều hướng',
'inspector.openWith': 'Mở bằng',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.saveToCollection': 'Lưu vào Bộ sưu tập',
'inspector.savedToCollection': 'Đã lưu',
+1 -1
View File
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
'places.unplanned': 'Không có kế hoạch',
'places.planned': 'Có kế hoạch',
'places.filterTracks': 'Bài hát',
'places.sortByRating': 'Sắp xếp theo đánh giá',
'places.filterByRating': 'Lọc theo đánh giá',
'places.yourRating': 'Đánh giá của bạn',
'places.notRated': 'Chưa có đánh giá',
'places.search': 'Tìm kiếm địa điểm...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': '已確認預訂',
'inspector.pendingRes': '待確認預訂',
'inspector.google': 'Google Maps',
'inspector.navigation': '導航',
'inspector.openWith': '開啟方式',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': '開啟網站',
'inspector.saveToCollection': '儲存到收藏',
+1 -1
View File
@@ -44,7 +44,7 @@ const places: TranslationStrings = {
'places.unplanned': '未規劃',
'places.planned': '已規劃',
'places.filterTracks': '路線',
'places.sortByRating': '依評分排序',
'places.filterByRating': '依評分篩選',
'places.yourRating': '你的評分',
'places.notRated': '尚未評分',
'places.search': '搜尋地點...',
+2
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': '已确认预订',
'inspector.pendingRes': '待确认预订',
'inspector.google': 'Google 地图',
'inspector.navigation': '导航',
'inspector.openWith': '打开方式',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': '打开网站',
'inspector.saveToCollection': '保存到收藏',
+1 -1
View File
@@ -44,7 +44,7 @@ const places: TranslationStrings = {
'places.unplanned': '未规划',
'places.planned': '已规划',
'places.filterTracks': '路线',
'places.sortByRating': '按评分排序',
'places.filterByRating': '按评分筛选',
'places.yourRating': '你的评分',
'places.notRated': '暂无评分',
'places.search': '搜索地点...',