fix(places): say where a result came from, and stop asking questions in the form

Six changes to the place picker and the card behind it, all of them about a
panel saying less and meaning more.

The TREK place index has no switch in the admin panel any more. It decides
whether a search leaves the instance at all, which is a property of the
deployment rather than a taste: an operator pins TREK_PLACES_ENABLED=false in
their compose file, and it cannot be turned off from a browser by whoever holds
an admin account that day. The switch never shipped, so nothing to migrate.

The standing "Using OpenStreetMap" notice above the search box is gone. It was
a sentence about configuration in the middle of a form about a place, and it
answered a question nobody asked while leaving the real one open. Each result
now carries a small mark instead: TREK, OpenStreetMap or Google. It sits on the
row rather than above the list, because a list can be two indexes interleaved
and a heading could only name one of them.

The saved-places filters open across the full width of the row. Two of them sit
side by side, only one can be open at a time, and list names were being cut at
about ten characters for no reason.

Three empty states use the mascot the rest of TREK uses. The place details panel
no longer tells the reader to go and ask an administrator for a Google key, the
saved-places list and the road trip rail no longer explain themselves in two
lines of grey text. That needed one new mood, `sad`: `sleepy` reads as falling
asleep, which is a different thing from finding nothing.

The Kind of stop field is out of the place form. It is a road trip concept, and
the road trip view already sets it from the stop's own number, where the choice
is next to what it changes.
This commit is contained in:
Maurice
2026-09-08 17:06:09 +02:00
committed by Maurice
parent 1761f2bcd5
commit 4a88dbcbcf
92 changed files with 283 additions and 557 deletions
-2
View File
@@ -661,8 +661,6 @@ export const adminApi = {
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
getTrekPlaces: () => apiClient.get('/admin/trek-places').then(r => r.data),
updateTrekPlaces: (enabled: boolean) => apiClient.put('/admin/trek-places', { enabled }).then(r => r.data),
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
@@ -5,6 +5,7 @@ import { collectionsApi } from '../../api/collections'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import EmptyState from '../shared/EmptyState'
interface LocationBias {
low: { lat: number; lng: number }
@@ -53,7 +54,16 @@ async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
return merged
}
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
/**
* Compact click-away dropdown (Tailwind, this panel lives outside .trek-dash).
*
* The panel is positioned against the FILTER ROW rather than against this
* button, so it opens across the full width of the row instead of the half its
* own trigger occupies. The two filters sit side by side and only one can be
* open at a time, so the wider panel costs nothing and stops list names being
* truncated at roughly ten characters. The row carries the `relative` this
* needs; there is no other call site.
*/
function FilterDropdown({ current, options, onSelect, lead }: {
current: string | number
options: Opt[]
@@ -72,7 +82,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
}, [open])
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="relative min-w-0 flex-1" ref={ref}>
<div className="min-w-0 flex-1" ref={ref}>
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
@@ -80,7 +90,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
<ChevronDown size={13} className="shrink-0 text-content-faint" />
</button>
{open && (
<div role="listbox" className="absolute z-30 left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
<div role="listbox" className="absolute z-30 top-full left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
{options.map(o => (
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
@@ -189,7 +199,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
/>
</div>
{lists.length > 0 && (
<div className="flex gap-2">
<div className="relative flex gap-2">
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
</div>
@@ -201,7 +211,13 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
<Loader2 size={18} className="animate-spin" />
</div>
) : visible.length === 0 ? (
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
<EmptyState
scene="search"
title={t('collections.picker.empty')}
size={84}
surface="var(--bg-secondary)"
compact
/>
) : (
<div className="flex flex-col gap-1">
{page.map(place => (
@@ -467,41 +467,33 @@ describe('PlaceDetailsColumn — hours and rating', () => {
})
})
/**
* FE-PDC-027..029 — the nudge towards a Google key.
* FE-PDC-027 — nothing came back for this place.
*
* Deliberately narrow: it appears only when the free sources came up empty AND
* no key is configured. On an instance with a key there is nothing to suggest,
* and on a place the free sources described it would read as an advert.
* It used to be a line of grey text plus, when no Google key was set, a panel
* telling the reader to go and ask an administrator for one. The mascot says the
* same thing in the shape every other empty state in TREK uses, and whose key is
* missing is not this panel's business.
*/
describe('PlaceDetailsColumn — no Google key', () => {
const empty = () => placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
describe('PlaceDetailsColumn — nothing found', () => {
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
renderColumn()
it('FE-PDC-027: suggests a key when nothing was found and none is set', async () => {
empty()
renderColumn({ hasMapsKey: false })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHint')).toBeInTheDocument()
})
it('FE-PDC-028: stays quiet when a key is already configured', async () => {
empty()
renderColumn({ hasMapsKey: true })
await screen.findByText('places.details.nothing')
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyHint')).not.toBeInTheDocument()
})
it('FE-PDC-029: stays quiet when the free sources did find something', async () => {
it('FE-PDC-028: stays quiet when the free sources did find something', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
})
renderColumn({ hasMapsKey: false })
renderColumn()
await screen.findByText('Ein Museum.')
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
})
})
/**
@@ -10,13 +10,11 @@ import {
ChevronUp,
Clock,
ExternalLink,
ImageOff,
Landmark,
Leaf,
Loader2,
ScrollText,
ShoppingBag,
Sparkles,
Sprout,
Star,
Sun,
@@ -27,6 +25,7 @@ import { mapsApi } from '../../api/client'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
import { safeHttpUrl } from '../../utils/safeUrl'
import EmptyState from '../shared/EmptyState'
import type { TranslationFn } from '../../types'
/** The place the column is describing. Null while nothing is selected. */
@@ -53,7 +52,6 @@ interface PlaceDetailsColumnProps {
/** For grouping the rating count's digits. */
locale?: string
/** False on an instance with no Google key, which is most of them. */
hasMapsKey?: boolean
t: TranslationFn
}
@@ -119,7 +117,6 @@ export default function PlaceDetailsColumn({
language,
timeFormat = '24h',
locale = 'en-US',
hasMapsKey = false,
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
@@ -223,17 +220,20 @@ export default function PlaceDetailsColumn({
<p className="text-caption text-content-muted">{t('places.details.disabled')}</p>
)}
{/* The mascot rather than a line of grey text and, under it, a paragraph
telling the reader to go and ask an administrator for a Google key.
Nothing came back for this place, which is worth saying once and
plainly; whose key is missing is not this panel's business, and on a
place the free sources describe fine it read as an advert. */}
{isEmpty && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-caption text-content-muted">
<ImageOff className="w-4 h-4 shrink-0" />
{t('places.details.nothing')}
</div>
{/* Only when both are true. With a key configured there is nothing
to suggest, and on a place the free sources DID describe the
suggestion would be an advert. */}
{!hasMapsKey && <NoKeyHint t={t} />}
</div>
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
surface="var(--bg-secondary)"
compact
/>
)}
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
@@ -462,19 +462,6 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
* It also names who to ask: the key is an instance-wide setting, so on most
* installs the person reading this cannot act on it themselves.
*/
function NoKeyHint({ t }: { t: TranslationFn }): React.ReactElement {
return (
<div className="rounded-xl border border-accent/25 bg-accent-subtle p-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text">
<Sparkles className="h-3 w-3" />
</span>
<p className="text-caption font-semibold text-content">{t('places.details.noKeyTitle')}</p>
</div>
<p className="mt-2 text-caption leading-relaxed text-content-secondary">{t('places.details.noKeyHint')}</p>
</div>
)
}
/**
* The star rating, as stars.
@@ -349,10 +349,55 @@ describe('PlaceFormModal', () => {
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
// hasMapsKey is false by default in beforeEach
it('FE-PLANNER-PLACEFORM-022: no standing notice about which index is in use', () => {
// It used to say "Using OpenStreetMap" above the box whenever no Google key
// was set, which was a sentence about configuration in the middle of a form
// about a place. Each result names its own source now, which is the question
// people actually had.
render(<PlaceFormModal {...defaultProps} />);
expect(screen.getByText(/OpenStreetMap/i)).toBeInTheDocument();
expect(screen.queryByText(/Using OpenStreetMap/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022b: a suggestion says which index answered', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'gers:abc', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'trek-places',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
expect(await screen.findByText('TREK')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022c: an interleaved result list marks each row with its own index', async () => {
// The whole point of putting it on the row: with both indexes answering,
// a label above the list could only name one of them. The index marks its
// own rows, so the unmarked one is OpenStreetMap by elimination.
const user = userEvent.setup();
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [
{ name: 'Eiffel Tower', address: 'Paris', lat: '48.85', lng: '2.29', source: 'trek-places' },
{ name: 'Champ de Mars', address: 'Paris', lat: '48.85', lng: '2.29' },
],
source: 'trek-places+openstreetmap',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await user.keyboard('{Enter}');
expect(await screen.findByText('TREK')).toBeInTheDocument();
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
});
// ── Category ─────────────────────────────────────────────────────────────────
@@ -1,7 +1,6 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import Modal from '../shared/Modal'
import type { RoadtripStopType } from '@trek/shared'
import { STOP_KINDS } from '../Roadtrip/stopKinds'
import CustomSelect from '../shared/CustomSelect'
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
import { mapsApi } from '../../api/client'
@@ -56,6 +55,49 @@ interface PlaceFormModalProps {
}
/**
* Which index a search result came from, as a short mark beside it.
*
* A place list can be two indexes interleaved, so the source belongs on the row
* rather than above the list: with the TREK index and OpenStreetMap answering
* together, "one of these came from somewhere" is not an answer anyone can use.
*
* The three names are proper nouns, so they are not translated, and that is also
* why there is no fourth: a source without a name people already know would need
* a string in 23 languages to say less than nothing.
*/
const SOURCE_LABELS: Record<string, string> = {
'trek-places': 'TREK',
openstreetmap: 'OpenStreetMap',
nominatim: 'OpenStreetMap',
google: 'Google',
}
/**
* The label for one row.
*
* A place carries its own source when the index that produced it says so, which
* is what makes an interleaved list readable. Everything else falls back to what
* answered the call: Google never marks its places, and a merged list marks only
* the index side, so an unmarked row in one is OpenStreetMap by elimination.
*/
function sourceLabelFor(place: unknown, listSource: string): string | null {
const own = (place as { source?: unknown } | null)?.source
if (typeof own === 'string' && own) return SOURCE_LABELS[own] ?? null
if (listSource.includes('openstreetmap')) return SOURCE_LABELS.openstreetmap
return SOURCE_LABELS[listSource] ?? null
}
/** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */
function SourceBadge({ label }: { label: string | null }) {
if (!label) return null
return (
<span className="shrink-0 rounded-md border border-edge bg-surface-secondary px-1.5 py-0.5 text-[10px] font-medium text-content-faint">
{label}
</span>
)
}
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
* render over the form fields. */
@@ -92,10 +134,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
} = props
// Hidden while the addon is off, because the kinds only mean anything to the road trip
// rail: on an instance without it they would be six labels that change nothing.
const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip'))
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
/** What answered the last full search. Only a fallback: a merged list carries the source per place. */
const [searchSource, setSearchSource] = useState<string>('')
/**
* What produced the list currently on screen, kept for the shadow log: the
* query as typed and the provider the envelope named. A ref rather than
@@ -119,6 +162,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const fileRef = useRef(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
// Which index answered the last keystroke. One call is served by one source,
// so the whole list carries it; the search below is per place, because that
// list can be two indexes interleaved.
const [acSource, setAcSource] = useState<string>('')
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const acAbortRef = useRef<AbortController | null>(null)
@@ -255,6 +302,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
acMetaRef.current = { query, source: result.source || 'unknown' }
setAcSuggestions(result.suggestions || [])
setAcSource(result.source || '')
setAcHighlight(-1)
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return
@@ -315,6 +363,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
setMapsResults(result.places || [])
setSearchSource(result.source || '')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
@@ -569,7 +618,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -589,6 +637,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
fileRef,
acSuggestions,
setAcSuggestions,
acSource,
searchSource,
acHighlight,
setAcHighlight,
acDebounceRef,
@@ -642,7 +692,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -662,6 +711,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
fileRef,
acSuggestions,
setAcSuggestions,
acSource,
searchSource,
acHighlight,
setAcHighlight,
acDebounceRef,
@@ -743,18 +794,12 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
language={language}
timeFormat={S.timeFormat}
locale={S.locale}
hasMapsKey={S.hasMapsKey}
t={t}
/>
)}
<form onSubmit={handleSubmit} className={twoColumn || showDetails ? 'flex-1 min-w-0 space-y-3' : 'space-y-3'} onPaste={handlePaste}>
{/* Place Search */}
<div className="bg-surface-secondary rounded-xl p-3 border border-edge">
{!hasMapsKey && (
<p className="mb-2 text-xs text-content-faint">
{t('places.osmActive')}
</p>
)}
<div className="relative">
<div className="flex gap-2">
<input
@@ -795,10 +840,15 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
idx === acHighlight ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'
}`}
>
<div className="font-medium text-sm">{s.mainText}</div>
{s.secondaryText && (
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
)}
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">{s.mainText}</div>
{s.secondaryText && (
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
)}
</div>
<SourceBadge label={sourceLabelFor(s, acSource)} />
</div>
</button>
))}
</div>
@@ -815,8 +865,13 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
className="w-full text-left px-3 py-2 hover:bg-surface-hover border-b border-edge-faint last:border-0"
>
<div className="font-medium text-sm">{result.name}</div>
<div className="text-xs text-content-muted truncate">{result.address}</div>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">{result.name}</div>
<div className="text-xs text-content-muted truncate">{result.address}</div>
</div>
<SourceBadge label={sourceLabelFor(result, searchSource)} />
</div>
</button>
))}
</div>
@@ -914,30 +969,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
</div>
</div>
{/* What kind of stop this is on a drive.
Under the category rather than beside it, because the two answer different
questions: a category is the traveller's own label, editable and shared across
the whole instance, while this is a fact about the place. Refuelling is not a
taste. Shown always, not only in road trip mode: a fuel stop added on a Tuesday
is still a fuel stop when the mode is switched on later, and this is the only
place in the web UI where the kind can be taken off again. */}
{roadtripEnabled ? (
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('roadtrip.stop.kind')}</label>
<CustomSelect
value={form.stop_type ?? ''}
onChange={value => handleChange('stop_type', String(value) as RoadtripStopType | '')}
placeholder={t('roadtrip.stop.none')}
options={[
{ value: '', label: t('roadtrip.stop.none') },
...STOP_KINDS.map(k => ({ value: k.key, label: t(k.labelKey) })),
]}
size="sm"
/>
<p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.kindHelp')}</p>
</div>
) : null}
{/* Category */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formCategory')}</label>
@@ -168,11 +168,14 @@ describe('RoadtripSidebar', () => {
expect(screen.getByText('Hamburg').closest('button')).not.toHaveAttribute('aria-current')
})
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive explains itself', () => {
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive shows the empty state, not a zeroed rail', () => {
// Nothing to total up, so no "0 km" standing over an empty list. The mascot
// is the shape every other empty state in TREK uses.
wrap(<RoadtripSidebar routes={routes({ days: [], totalDistance: 0, totalDuration: 0, totalStops: 0 })} />)
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
expect(screen.getByText('No route yet')).toBeInTheDocument()
expect(screen.queryByText('Hamburg')).not.toBeInTheDocument()
expect(screen.queryByText(/0 km/)).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-010: each day carries its own distance and time', () => {
@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import {
MapPin, CarFront, Footprints, Bike, Zap, AlertTriangle, Moon,
CarFront, Footprints, Bike, Zap, AlertTriangle, Moon,
ParkingSquare, Shuffle, Fuel, Clock, Spline,
type LucideIcon,
} from 'lucide-react'
@@ -18,6 +18,7 @@ import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useR
import type { RouteVia } from '../../types'
import { FS } from './typeScale'
import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState'
interface RoadtripSidebarProps {
/** Legs and totals for the whole trip, computed once in the planner hook. */
@@ -1045,11 +1046,17 @@ export default function RoadtripSidebar({
if (routes.days.length === 0) {
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="px-5 py-8 text-center">
<MapPin size={18} className="mx-auto mb-2 text-content-faint" aria-hidden />
<h3 className="font-semibold text-content" style={{ fontSize: FS.name }}>{t('roadtrip.empty.title')}</h3>
<p className="mt-1 text-content-muted" style={{ fontSize: FS.meta }}>{t('roadtrip.empty.body')}</p>
</div>
{/* The mascot rather than a pin and two lines of instructions. The rail
is empty because the trip has no days with places yet, which is a
state, not a task list: the same look every other empty state in TREK
has. */}
<EmptyState
scene="transport"
mood="sad"
title={t('roadtrip.empty.title')}
size={96}
surface="var(--bg-secondary)"
/>
</div>
)
}
+12 -1
View File
@@ -37,7 +37,7 @@ export type TrekScene =
| 'search'
| 'tasks'
export type TrekMood = 'default' | 'happy' | 'sleepy' | 'confused' | 'error'
export type TrekMood = 'default' | 'happy' | 'sad' | 'sleepy' | 'confused' | 'error'
const MARK =
'M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z'
@@ -110,6 +110,17 @@ function Eyes({ mood }: { mood: TrekMood }) {
</g>
)
}
if (mood === 'sad') {
// The mirror of `happy`: the same arcs bent the other way. `sleepy` is a
// shallower dip and reads as eyes falling shut, which is a different thing
// from disappointed.
return (
<g fill="none" stroke="var(--m-bg)" strokeWidth="1.8" strokeLinecap="round">
<path d="M33.7 32 Q36 34.8 38.3 32" />
<path d="M42.7 32 Q45 34.8 47.3 32" />
</g>
)
}
if (mood === 'sleepy') {
return (
<g fill="none" stroke="var(--m-bg)" strokeWidth="1.8" strokeLinecap="round">
@@ -494,50 +494,15 @@ describe('AdminSettingsTab', () => {
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(true);
});
describe('FE-ADMSET-041: the index switch', () => {
// The one control on this card about egress rather than about spending:
// with the index on, every search and every keystroke in the place box
// leaves the instance. The flag was read by six call paths and written by
// nothing, so before this there was no way to turn it off short of an
// INSERT against app_settings by hand.
const switchFor = () =>
screen.getByRole('button', { name: /trek place index/i });
it('FE-ADMSET-041: the index has no switch, because turning it off is a deployment decision', () => {
// It decides whether a search leaves the instance at all, so it belongs in
// the compose file rather than in a browser: TREK_PLACES_ENABLED=false.
// A card that offered the choice here also had to explain what answers
// instead, which is a paragraph about configuration inside a card about a
// data source.
renderTab();
it('turns the index off and tells the server', async () => {
let sent: unknown = null;
server.use(
http.put('/api/admin/trek-places', async ({ request }) => {
sent = await request.json();
return HttpResponse.json({ enabled: false });
}),
);
const admin = renderTab({ trekPlacesEnabled: true });
expect(switchFor()).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(switchFor());
expect(admin.setTrekPlacesEnabledState).toHaveBeenCalledWith(false);
await waitFor(() => expect(sent).toEqual({ enabled: false }));
});
it('rolls back when the save fails, instead of showing a state the server never took', async () => {
server.use(http.put('/api/admin/trek-places', () => HttpResponse.error()));
const admin = renderTab({ trekPlacesEnabled: true });
fireEvent.click(switchFor());
await waitFor(() => expect(admin.setTrekPlacesEnabledState).toHaveBeenLastCalledWith(true));
});
it('says what answers instead, but only while it is off', () => {
renderTab({ trekPlacesEnabled: false });
expect(screen.getByText(/place search uses openstreetmap/i)).toBeInTheDocument();
expect(switchFor()).toHaveAttribute('aria-pressed', 'false');
});
it('stays quiet about the fallback while the index is on', () => {
renderTab({ trekPlacesEnabled: true });
expect(screen.queryByText(/place search uses openstreetmap/i)).not.toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: /trek place index/i })).not.toBeInTheDocument();
expect(screen.queryByText(/place search uses openstreetmap/i)).not.toBeInTheDocument();
});
});
+1 -10
View File
@@ -26,7 +26,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
placesEnrichEnabled, setPlacesEnrichEnabledState,
transitProvider, setTransitProviderState,
transitGoogleKeySource, setTransitGoogleKeySource,
trekPlacesEnabled, setTrekPlacesEnabledState,
placeShadowEnabled, setPlaceShadowEnabledState,
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
@@ -382,15 +381,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
{/* Three comparable blocks, in the order we would have people choose
them. Weather moved out entirely: it needs no key and had nothing
to configure, so it had no business in a card about keys. */}
<TrekApiCard
t={t}
enabled={trekPlacesEnabled}
onToggle={async () => {
const next = !trekPlacesEnabled
setTrekPlacesEnabledState(next)
try { await adminApi.updateTrekPlaces(next) } catch { setTrekPlacesEnabledState(!next) }
}}
/>
<TrekApiCard t={t} />
{!managed && (<>
<ProviderBlock title={t('admin.mapsKey')}>
+1 -24
View File
@@ -1,23 +1,10 @@
import React from 'react'
import { Check, ChevronRight, Globe2, KeyRound, Library, ShieldOff, WifiOff, X } from 'lucide-react'
import TrekMark from '../../components/shared/TrekMark'
import ToggleSwitch from '../../components/Settings/ToggleSwitch'
import type { TranslationFn } from '../../types'
interface TrekApiCardProps {
t: TranslationFn
/**
* Whether the index answers on this instance.
*
* A real switch rather than a badge, and the only one on this card that is
* about egress rather than about spending: with it on, every search and every
* keystroke in the place box leaves the instance for the service. An operator
* who does not want that has to be able to say so here — the flag was read by
* six call paths and written by nothing, so the only way to turn it off was an
* INSERT against app_settings by hand.
*/
enabled: boolean
onToggle: () => void
}
/**
@@ -39,7 +26,7 @@ interface TrekApiCardProps {
*/
const SOURCES = ['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia']
export default function TrekApiCard({ t, enabled, onToggle }: TrekApiCardProps): React.ReactElement {
export default function TrekApiCard({ t }: TrekApiCardProps): React.ReactElement {
// Reuses the words TREK already has for these fields wherever it has them,
// so the chip row costs two new strings instead of ten.
const fields = [
@@ -84,22 +71,12 @@ export default function TrekApiCard({ t, enabled, onToggle }: TrekApiCardProps):
<div className="px-5 pt-6 pb-4">
<div className="flex items-start justify-between gap-4">
<TrekMark className="h-8 w-auto text-content" aria-label="TREK Places API" />
<ToggleSwitch on={enabled} onToggle={onToggle} label={t('admin.trekApi.toggleLabel')} />
</div>
<p className="mt-3 text-sm leading-relaxed text-content-secondary">
{t('admin.trekApi.tagline')}
</p>
{/* Said only when it is off, and said plainly: this is the state where
searches stop using the index, and the reader should know what
answers instead rather than being left to guess. */}
{!enabled && (
<p className="mt-2 text-xs leading-relaxed text-content-faint">
{t('admin.trekApi.offNote')}
</p>
)}
<ul className="mt-4 grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2">
{facts.map(({ Icon, text }) => (
<li key={text} className="flex items-center gap-2 text-xs text-content-secondary">
-3
View File
@@ -114,8 +114,6 @@ export function useAdmin() {
// false rather than the true the four switches above start from.
// The index switch. Read fail-open like the server does, so the state shown
// before the request lands matches what an unset row actually means.
const [trekPlacesEnabled, setTrekPlacesEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getTrekPlaces().then(d => setTrekPlacesEnabledState(d.enabled)).catch(() => {}) }, [])
const [placeShadowEnabled, setPlaceShadowEnabledState] = useState<boolean>(false)
useEffect(() => { adminApi.getPlaceShadow().then(d => setPlaceShadowEnabledState(d.enabled)).catch(() => {}) }, [])
@@ -453,7 +451,6 @@ export function useAdmin() {
placesEnrichEnabled, setPlacesEnrichEnabledState,
transitProvider, setTransitProviderState,
transitGoogleKeySource, setTransitGoogleKeySource,
trekPlacesEnabled, setTrekPlacesEnabledState,
placeShadowEnabled, setPlaceShadowEnabledState,
collabFeatures, setCollabFeatures,
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,