mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(tours): hiking-rule walking time, source marks in the map search, tour rows and status badges reworked, ferrata card in the map glass
This commit is contained in:
@@ -5,10 +5,20 @@ import type { Ferrata } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow';
|
||||
import { SoftPill } from '../Planner/planParts';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
|
||||
import { ferrataOsmUrl } from './ferrataModel';
|
||||
|
||||
/** The map controls' frosted glass, so the card reads as one of them. */
|
||||
const MAP_GLASS = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
};
|
||||
|
||||
/**
|
||||
* One via ferrata, picked on the map: its name, the route it belongs to, its
|
||||
* grade range and total length as badges, and the way back to the source.
|
||||
@@ -21,10 +31,13 @@ export default function FerrataDetailCard({
|
||||
group,
|
||||
segment,
|
||||
onClose,
|
||||
width,
|
||||
}: {
|
||||
group: FerrataGroup;
|
||||
segment: Ferrata;
|
||||
onClose?: () => void;
|
||||
/** Set when the card stands on another control and takes its width. */
|
||||
width?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit);
|
||||
@@ -37,13 +50,14 @@ export default function FerrataDetailCard({
|
||||
|
||||
return (
|
||||
<article
|
||||
className="w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem] rounded-xl border border-edge bg-surface-card p-3 text-content shadow-lg"
|
||||
className={`rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
|
||||
style={{ ...MAP_GLASS, width }}
|
||||
data-testid="ferrata-detail-card"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Route size={16} className="mt-0.5 flex-none text-content-muted" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-2 break-words text-subtitle font-semibold" title={name}>
|
||||
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={name}>
|
||||
{name}
|
||||
</h3>
|
||||
{route && <p className="truncate text-caption text-content-muted">{route}</p>}
|
||||
@@ -71,12 +85,15 @@ export default function FerrataDetailCard({
|
||||
href={ferrataOsmUrl(segment)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1 text-body font-medium text-accent-on hover:underline"
|
||||
className="mt-3 inline-flex items-center gap-1 font-medium text-content hover:underline"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t('ferratas.viewInOsm')}
|
||||
<ExternalLink size={13} aria-hidden="true" />
|
||||
</a>
|
||||
<p className="mt-2 text-caption text-content-faint">{t('ferratas.attribution')}</p>
|
||||
<p className="mt-2 border-t border-edge-faint pt-2 text-content-faint" style={fs(9)}>
|
||||
{t('ferratas.attribution')}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,12 +22,15 @@ export default function FerrataDetailOverlay({
|
||||
onOutsidePress,
|
||||
insetEnd,
|
||||
bottom,
|
||||
width,
|
||||
}: {
|
||||
selection: FerrataSelection | null;
|
||||
onClose: () => void;
|
||||
onOutsidePress?: () => void;
|
||||
insetEnd: number;
|
||||
bottom: number;
|
||||
/** A fixed width, to match what the card stands on (the grade legend in 3D). */
|
||||
width?: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const open = !!selection;
|
||||
@@ -55,11 +58,11 @@ export default function FerrataDetailOverlay({
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute z-[20] flex justify-end"
|
||||
className="absolute z-[20] flex justify-end transition-[bottom] duration-200 ease-out"
|
||||
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
|
||||
data-testid="ferrata-detail-overlay"
|
||||
>
|
||||
<FerrataDetailCard group={selection.group} segment={selection.segment} onClose={onClose} />
|
||||
<FerrataDetailCard group={selection.group} segment={selection.segment} onClose={onClose} width={width} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { MapPin, Search } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
|
||||
import { useSuggestionDropdown } from './useSuggestionDropdown'
|
||||
import { PlaceSuggestion, SuggestionInputBox, SuggestionList, SuggestionRow } from './SuggestionDropdown'
|
||||
import { SoftPill } from './planParts'
|
||||
|
||||
export interface LocationPoint {
|
||||
name: string
|
||||
@@ -30,9 +32,11 @@ interface Props {
|
||||
* name; from the third character on the map search answers instead.
|
||||
*/
|
||||
places?: LocationPoint[]
|
||||
/** Marks each search result with where it came from (OSM, Google, TREK, a plugin), as the place form does. */
|
||||
showSource?: boolean
|
||||
}
|
||||
|
||||
export default function LocationSelect({ value, onChange, placeholder, ariaLabel, showSearchStatus = false, floating = false, style, places }: Props) {
|
||||
export default function LocationSelect({ value, onChange, placeholder, ariaLabel, showSearchStatus = false, floating = false, style, places, showSource = false }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
const placeLang = usePlaceLanguage()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
@@ -40,6 +44,9 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
|
||||
const [query, setQuery] = useState(value?.name || '')
|
||||
const { open, setOpen, highlight, setHighlight, wrapRef, handleKey } = useSuggestionDropdown()
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
// What answered the last search, for the rows that do not name their own source.
|
||||
const [listSource, setListSource] = useState('')
|
||||
const { sourceLabel } = usePlaceSuggestions()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchError, setSearchError] = useState(false)
|
||||
// The text the last answer was for, so a status never describes older text.
|
||||
@@ -72,6 +79,7 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
|
||||
const data = await mapsApi.search(trimmed, placeLang, locationBias)
|
||||
if (stale) return
|
||||
setResults(data.places || [])
|
||||
setListSource(data.source || '')
|
||||
setHighlight(-1)
|
||||
setSearchedQuery(trimmed)
|
||||
} catch {
|
||||
@@ -170,6 +178,7 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
|
||||
optionId={showSearchStatus ? `${listId}-option-${i}` : undefined}
|
||||
>
|
||||
<PlaceSuggestion name={r.name} address={r.address} />
|
||||
{showSource && !showPicks && <SourceMark label={sourceLabel(r, listSource)} />}
|
||||
</SuggestionRow>
|
||||
))}
|
||||
</SuggestionList>
|
||||
@@ -178,6 +187,12 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
|
||||
)
|
||||
}
|
||||
|
||||
/** Where a result came from, quiet on purpose, beside its name. */
|
||||
function SourceMark({ label }: { label: string | null }) {
|
||||
if (!label) return null
|
||||
return <span className="flex-none self-center"><SoftPill>{label}</SoftPill></span>
|
||||
}
|
||||
|
||||
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
|
||||
const showPicks = !value && typed.length < 3 && !!places?.length
|
||||
const rows: (T | LocationPoint)[] = showPicks
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useId, type ReactNode } from 'react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { Link2 } from 'lucide-react'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { SoftPill } from '../Planner/planParts'
|
||||
import { TourFactPills, TourGradeTile } from './tourParts'
|
||||
import { tourWebsitePresentation } from './tourPresentation'
|
||||
|
||||
@@ -16,7 +17,6 @@ interface TourListRowProps {
|
||||
|
||||
/** A tour in a list, shaped like a place row: tile, name, its facts as pills and the day action on the right. */
|
||||
export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const nameId = useId()
|
||||
const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover'
|
||||
const website = tourWebsitePresentation(tour.website)
|
||||
@@ -37,28 +37,32 @@ export default function TourListRow({ tour, selected = false, disabled = false,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
{/* The tile, the name and the day actions share the head line; the rest runs the
|
||||
full width under them, below the tile too. */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<TourGradeTile tour={tour} />
|
||||
<div className="min-w-0 flex-1 pt-px">
|
||||
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
|
||||
<TourFactPills tour={tour} className="mt-1.5" />
|
||||
{tour.description && (
|
||||
<div data-testid="tour-description-preview" className="mt-1 overflow-hidden text-ellipsis text-content-faint" style={{ ...fs(11), lineHeight: 1.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{tour.description}
|
||||
</div>
|
||||
)}
|
||||
{website && (
|
||||
<div className="mt-1 truncate text-content-faint" style={fs(10.5)}>
|
||||
{website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span id={nameId} className="block min-w-0 flex-1 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
|
||||
{action && (
|
||||
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5 pt-1" onClick={event => event.stopPropagation()}>
|
||||
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5" onClick={event => event.stopPropagation()}>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{tour.description && (
|
||||
<div data-testid="tour-description-preview" className="mt-2.5 rounded-[10px] bg-surface-card px-2 py-1.5 text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint" style={fs(11)}>
|
||||
<span className="overflow-hidden text-ellipsis" style={{ lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{tour.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<TourFactPills tour={tour} className={tour.description ? 'mt-1.5' : 'mt-2.5'} />
|
||||
{website && (
|
||||
<div className="mt-1 flex min-w-0">
|
||||
<SoftPill icon={<Link2 size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
<span className="min-w-0 truncate">{website.provider ?? website.domain}</span>
|
||||
</SoftPill>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// FE-TOURS-HIKINGTIME-001 to -003: walking time after DIN 33466.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hikingTimeMinutes } from './hikingTime';
|
||||
|
||||
describe('hikingTimeMinutes', () => {
|
||||
it('FE-TOURS-HIKINGTIME-001: on the flat it is 4 km an hour', () => {
|
||||
expect(hikingTimeMinutes(8, 0, 0)).toBe(120);
|
||||
});
|
||||
|
||||
it('FE-TOURS-HIKINGTIME-002: a short steep climb is ruled by the height, the distance counts by half', () => {
|
||||
// 2 km and 450 m up: 1.5 h for the height plus half of 0.5 h for the distance.
|
||||
expect(hikingTimeMinutes(2, 450, 0)).toBe(105);
|
||||
});
|
||||
|
||||
it('FE-TOURS-HIKINGTIME-003: a long walk is ruled by the distance, the height counts by half', () => {
|
||||
// 12 km, 300 m up and 500 m down: 3 h for the distance plus half of 2 h for the height.
|
||||
expect(hikingTimeMinutes(12, 300, 500)).toBe(240);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Walking time the way hiking signposts in the Alps state it (DIN 33466, as the
|
||||
* DAV and the Swiss hiking trails use it): 4 km an hour on the flat, 300 m of
|
||||
* climb and 500 m of descent an hour, and of the time for the distance and the
|
||||
* time for the height only the larger counts in full, the smaller by half.
|
||||
*
|
||||
* The router's own estimate is a walking pace along the line and barely sees a
|
||||
* slope, so a short steep climb came out far too quick.
|
||||
*/
|
||||
const FLAT_KM_PER_HOUR = 4;
|
||||
const CLIMB_M_PER_HOUR = 300;
|
||||
const DESCENT_M_PER_HOUR = 500;
|
||||
|
||||
export function hikingTimeMinutes(distanceKm: number, climbM: number, descentM: number): number {
|
||||
const flat = distanceKm / FLAT_KM_PER_HOUR;
|
||||
const height = climbM / CLIMB_M_PER_HOUR + descentM / DESCENT_M_PER_HOUR;
|
||||
const hours = Math.max(flat, height) + Math.min(flat, height) / 2;
|
||||
return Math.round(hours * 60);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import CustomSelect from '../../shared/CustomSelect';
|
||||
import { INPUT, LABEL } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { FoldButton, TourNotice, TourSection } from '../tourParts';
|
||||
import { FoldButton, InfoDot, TourNotice, TourSection } from '../tourParts';
|
||||
import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
@@ -39,26 +38,6 @@ function writeDetailsOpen(open: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The round info dot beside a label; hover, focus and a tap show the explanation as a tooltip. */
|
||||
export function InfoDot({ description, label }: { description: string; label: string }) {
|
||||
const id = useId();
|
||||
return (
|
||||
<Tooltip label={description}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-describedby={id}
|
||||
className="grid h-5 w-5 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Info size={13} strokeWidth={2} aria-hidden="true" />
|
||||
<span id={id} className="sr-only">
|
||||
{description}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A field label with what belongs beside it (an info dot, a badge) on the same line. */
|
||||
function FieldLabel({ htmlFor, children, aside }: { htmlFor: string; children: ReactNode; aside?: ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -195,7 +195,6 @@ describe('TourPlannerToursRail', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Summit day/ }))
|
||||
|
||||
await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7))
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Tour saved')
|
||||
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add to day: Saved ridge walk' })).toBeEnabled()
|
||||
})
|
||||
@@ -563,7 +562,7 @@ describe('TourPlannerRail', () => {
|
||||
expect(screen.getAllByLabelText('Move waypoint up').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
|
||||
expect(screen.getAllByLabelText('Move waypoint down').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
|
||||
expect(screen.getAllByLabelText('Remove waypoint').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Saving…')
|
||||
expect(within(screen.getByRole('status')).getByLabelText('Saving…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('TOUR-PLANNER-RAIL-006: places the safety note beside Save and numbers rows like map pins', () => {
|
||||
@@ -813,12 +812,10 @@ describe('TourPlannerRail', () => {
|
||||
|
||||
it('TOUR-PLANNER-RAIL-019: restored new and saved drafts show a non-blocking notice', () => {
|
||||
const { rerender } = render(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Draft')
|
||||
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument()
|
||||
|
||||
rerender(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, editingPlaceId: tour.place_id, mode: { type: 'edit-saved', placeId: tour.place_id } })} />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Draft')
|
||||
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { AlertTriangle, ArrowLeft, Info, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowLeft, Circle, Info, Loader2, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'
|
||||
import type { Day } from '../../../types'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useSettingsStore } from '../../../store/settingsStore'
|
||||
@@ -58,9 +58,21 @@ function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; asid
|
||||
)
|
||||
}
|
||||
|
||||
/** A status as a small round icon beside the title, its words in the tooltip and for a screen reader. */
|
||||
function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' | 'info' | 'warning'; children: ReactNode }) {
|
||||
const look = tone === 'warning' ? 'text-warning' : tone === 'info' ? 'text-info' : 'text-content-muted'
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<span aria-label={label} className={`grid h-[20px] w-[20px] flex-none place-items-center rounded-full bg-surface-card shadow-sm ring-1 ring-inset ring-edge-faint ${look}`}>
|
||||
{children}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the editor is doing right now, as badges under the head's title: a
|
||||
* restored draft, routing or saving, and a failure with its retry. Nothing
|
||||
* What the editor is doing right now, as icon badges beside the head's title:
|
||||
* a restored draft, routing or saving, and a failure with its retry. Nothing
|
||||
* when there is nothing to say.
|
||||
*/
|
||||
function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
|
||||
@@ -71,19 +83,21 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
|
||||
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
|
||||
if (!restored && !statusKey && !failed) return null
|
||||
const warning = planner.error != null || canRetry
|
||||
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null
|
||||
const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation'
|
||||
return (
|
||||
<div role="status" data-testid="tour-planner-status" className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
|
||||
<span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1">
|
||||
{restored && (
|
||||
<Tooltip label={t('tours.planner.restored')}>
|
||||
<span className="inline-flex" aria-label={t('tours.planner.restored')}>
|
||||
<SoftPill tone="info" icon={<RotateCcw size={10} strokeWidth={2.2} className="flex-none" />}>{t('tours.planner.restoredShort')}</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<StatusDot label={t('tours.planner.restored')} tone="info">
|
||||
<RotateCcw size={11} strokeWidth={2.2} />
|
||||
</StatusDot>
|
||||
)}
|
||||
{(failed || statusKey) && (
|
||||
<SoftPill tone={warning ? 'warning' : 'neutral'} icon={warning ? <AlertTriangle size={10} strokeWidth={2.2} className="flex-none" /> : undefined}>
|
||||
{failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null}
|
||||
</SoftPill>
|
||||
{statusLabel && (
|
||||
<StatusDot label={statusLabel} tone={warning ? 'warning' : 'neutral'}>
|
||||
{warning
|
||||
? <AlertTriangle size={11} strokeWidth={2.2} />
|
||||
: busy ? <Loader2 size={11} strokeWidth={2.2} className="animate-spin" /> : <Circle size={7} strokeWidth={3} />}
|
||||
</StatusDot>
|
||||
)}
|
||||
{canRetry && (
|
||||
<button type="button" onClick={planner.retry}
|
||||
@@ -92,7 +106,7 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
|
||||
{t('tours.planner.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,7 +201,7 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
<BarButton label={t('tours.planner.backToTours')} onClick={() => requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
|
||||
<ArrowLeft size={16} strokeWidth={2} />
|
||||
</BarButton>
|
||||
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} sub={<RailStatusBadges planner={planner} />} />
|
||||
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} aside={<RailStatusBadges planner={planner} />} />
|
||||
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}>
|
||||
<Undo2 size={15} strokeWidth={2} />
|
||||
</BarButton>
|
||||
@@ -315,9 +329,14 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
const routeAnalysis = planner.routeAnalysis
|
||||
|
||||
// A finished save is said once, as the app says it everywhere: a toast.
|
||||
// Once per save: the toast helper and t are fresh objects on every render, so the
|
||||
// effect reruns often; only a save it has not announced yet gets the toast.
|
||||
const savedTour = planner.saveOutcome
|
||||
const announced = useRef<typeof savedTour>(null)
|
||||
useEffect(() => {
|
||||
if (savedTour) toast.success(t('tours.planner.saved'))
|
||||
if (!savedTour || announced.current === savedTour) return
|
||||
announced.current = savedTour
|
||||
toast.success(t('tours.planner.saved'))
|
||||
}, [savedTour, t, toast])
|
||||
|
||||
const openTour = async (tour: TourListItem) => {
|
||||
@@ -341,7 +360,9 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
|
||||
const tourTimeFields = [
|
||||
{
|
||||
label: t('tours.planner.inspector.duration'),
|
||||
label: t('tours.planner.walkingEstimated'),
|
||||
shortLabel: t('tours.planner.walkingEstimatedShort'),
|
||||
info: { label: t('tours.planner.walkingEstimatedInfoLabel'), description: t('tours.planner.walkingEstimatedInfo') },
|
||||
value: planner.walkingDurationMinutes == null
|
||||
? t('tours.planner.walkingTimeUnknown')
|
||||
: formatPlannedTourDuration(planner.walkingDurationMinutes),
|
||||
|
||||
@@ -20,6 +20,8 @@ vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detai
|
||||
vi.mock('../useTourPermissions', () => ({ useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({ canEdit, canAssign }) }))
|
||||
|
||||
import { useTourPlanner } from './useTourPlanner'
|
||||
import { hikingTimeMinutes } from '../hikingTime'
|
||||
import { analyzeRouteGeometry } from '../../../utils/routeGeometry'
|
||||
import { TourPlannerRail } from './TourPlannerPanels'
|
||||
|
||||
const routed = {
|
||||
@@ -28,6 +30,9 @@ const routed = {
|
||||
durationSeconds: 1800,
|
||||
}
|
||||
const enriched = [[48, 11, 500], [48.01, 11.02, 550]] as [number, number, number][]
|
||||
// With the heights in, the walking time is the hiking rule over the enriched line, not the router's.
|
||||
const enrichedAnalysis = analyzeRouteGeometry(JSON.stringify(enriched))!
|
||||
const enrichedWalkingSeconds = hikingTimeMinutes(enrichedAnalysis.distanceKm, enrichedAnalysis.gain!, enrichedAnalysis.loss!) * 60
|
||||
const savedTour = {
|
||||
place_id: 42,
|
||||
name: 'Saved ridge',
|
||||
@@ -299,7 +304,7 @@ describe('useTourPlanner', () => {
|
||||
expect(result.current.canSave).toBe(true)
|
||||
await act(async () => { await result.current.save() })
|
||||
expect(createTour).toHaveBeenCalledWith(73, expect.objectContaining({
|
||||
duration_seconds: routed.durationSeconds,
|
||||
duration_seconds: enrichedWalkingSeconds,
|
||||
planned_duration_minutes: 95,
|
||||
break_additional_minutes: 40,
|
||||
}))
|
||||
@@ -311,13 +316,15 @@ describe('useTourPlanner', () => {
|
||||
act(() => result.current.setPlannedDurationMinutes(null))
|
||||
await act(async () => { await result.current.save() })
|
||||
expect(updateTour).toHaveBeenCalledWith(73, savedTour.place_id, expect.objectContaining({
|
||||
duration_seconds: routed.durationSeconds,
|
||||
duration_seconds: enrichedWalkingSeconds,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: 40,
|
||||
}))
|
||||
})
|
||||
|
||||
it('RS-02: recalculates automatic total when route changes but preserves a manual override', async () => {
|
||||
// No heights for this line, so the router's time stands and each new route brings its own.
|
||||
enrichTourElevations.mockResolvedValue(routed.coordinates)
|
||||
routeWalkingTour
|
||||
.mockResolvedValueOnce({ ...routed, durationSeconds: 3600 })
|
||||
.mockResolvedValueOnce({ ...routed, durationSeconds: 5400 })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { randomId } from '../../../utils/randomId'
|
||||
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'
|
||||
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
|
||||
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint } from './tourRouting'
|
||||
import { hikingTimeMinutes } from '../hikingTime'
|
||||
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
|
||||
|
||||
export interface TourPlannerWaypoint {
|
||||
@@ -417,7 +418,15 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const websiteInvalid = website.trim().length > 0 && !tourWebsiteSchema.safeParse(website).success
|
||||
const plannedDurationInvalid = plannedDurationMinutes !== null
|
||||
&& (!Number.isInteger(plannedDurationMinutes) || plannedDurationMinutes < 0 || plannedDurationMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES)
|
||||
const walkingDurationMinutes = durationSeconds === null ? null : Math.round(durationSeconds / 60)
|
||||
const routeAnalysis = useMemo(
|
||||
() => analyzeRouteGeometry(JSON.stringify(enrichedGeometry ?? route)),
|
||||
[enrichedGeometry, route],
|
||||
)
|
||||
// The router's time only stands in until the route has its heights; from then on the
|
||||
// walking time is the signposts' rule over distance, climb and descent.
|
||||
const walkingDurationMinutes = routeAnalysis?.gain != null && routeAnalysis.loss != null
|
||||
? hikingTimeMinutes(routeAnalysis.distanceKm, routeAnalysis.gain, routeAnalysis.loss)
|
||||
: durationSeconds === null ? null : Math.round(durationSeconds / 60)
|
||||
const automaticPlannedTotalMinutes = walkingDurationMinutes === null
|
||||
? null
|
||||
: walkingDurationMinutes + (breakAdditionalMinutes ?? 0)
|
||||
@@ -603,10 +612,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
}, [interruptRouting])
|
||||
|
||||
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints])
|
||||
const routeAnalysis = useMemo(
|
||||
() => analyzeRouteGeometry(JSON.stringify(enrichedGeometry ?? route)),
|
||||
[enrichedGeometry, route],
|
||||
)
|
||||
const readOnlyGpxAnalysis = useMemo(
|
||||
() => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry),
|
||||
[readOnlyGpxTour?.routeGeometry],
|
||||
@@ -713,7 +718,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
sequence,
|
||||
})),
|
||||
max_hiking_difficulty: maxHikingDifficulty,
|
||||
duration_seconds: durationSeconds,
|
||||
duration_seconds: walkingDurationMinutes === null ? durationSeconds : walkingDurationMinutes * 60,
|
||||
planned_duration_minutes: plannedDurationMinutes,
|
||||
break_additional_minutes: breakAdditionalMinutes,
|
||||
} as const
|
||||
@@ -755,7 +760,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
}, [breakAdditionalMinutes, canSave, description, durationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, plannedDurationMinutes, storageKey, tripId, waypoints, website])
|
||||
}, [breakAdditionalMinutes, canSave, description, durationSeconds, walkingDurationMinutes, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, plannedDurationMinutes, storageKey, tripId, waypoints, website])
|
||||
|
||||
return {
|
||||
canEdit, canAssign,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FerrataBbox } from '@trek/shared';
|
||||
import { ChevronDown, ChevronUp, Gauge, Palette, Pause, Play, RotateCcw, Scan, TrendingUp } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
|
||||
@@ -44,6 +44,14 @@ export interface TourTerrainMapProps {
|
||||
onControlsHeightChange?: (px: number) => void;
|
||||
/** The "set points" switch in the layer pill, while a tour is being edited. */
|
||||
placeTool?: MapPlaceTool;
|
||||
/** The grade legend's width and how far its open bands reach above it, for a card that sits on it; null while there is none. */
|
||||
onLegendLayout?: (layout: TerrainLegendLayout | null) => void;
|
||||
}
|
||||
|
||||
export interface TerrainLegendLayout {
|
||||
width: number;
|
||||
/** How far the unfolded bands reach above the legend pill, 0 while they are folded. */
|
||||
raisedBy: number;
|
||||
}
|
||||
|
||||
const SPEEDS = [1, 2, 4] as const;
|
||||
@@ -67,7 +75,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
||||
const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } });
|
||||
const stats = useMemo(() => terrainStats(samples), [samples]);
|
||||
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
|
||||
const legend = terrain.canReplay ? <GradeLegend stats={stats} unit={unit} /> : null;
|
||||
const legend = terrain.canReplay ? <GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} /> : null;
|
||||
const legendAbove = fit === 'stacked';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -270,15 +278,46 @@ function PreloadRing({ share, label }: { share: number; label: string }) {
|
||||
}
|
||||
|
||||
/** The grade mix as a pill like the dock; it unfolds upwards into the bands. */
|
||||
function GradeLegend({ stats, unit }: { stats: TerrainStats; unit: DistanceUnit }) {
|
||||
function GradeLegend({
|
||||
stats,
|
||||
unit,
|
||||
onLayout,
|
||||
}: {
|
||||
stats: TerrainStats;
|
||||
unit: DistanceUnit;
|
||||
onLayout?: (layout: TerrainLegendLayout | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
// A card over the legend takes its width and stands on its bands when they are open.
|
||||
useLayoutEffect(() => {
|
||||
const wrap = wrapRef.current;
|
||||
if (!wrap || !onLayout) return;
|
||||
const report = () => {
|
||||
const panel = panelRef.current;
|
||||
onLayout({ width: wrap.offsetWidth, raisedBy: open && panel ? panel.offsetHeight + 8 : 0 });
|
||||
};
|
||||
report();
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(report);
|
||||
observer.observe(wrap);
|
||||
if (panelRef.current) observer.observe(panelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [open, onLayout]);
|
||||
useEffect(() => () => onLayout?.(null), [onLayout]);
|
||||
const total = stats.totalMeters || 1;
|
||||
const width = (key: GradeBandKey) => `${(stats.bandMeters[key] / total) * 100}%`;
|
||||
return (
|
||||
<div className="pointer-events-auto relative">
|
||||
<div ref={wrapRef} className="pointer-events-auto relative">
|
||||
{open && (
|
||||
<div id="tour-terrain-grades" className="absolute inset-x-0 bottom-full mb-2 rounded-2xl p-3" style={GLASS}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
id="tour-terrain-grades"
|
||||
className="absolute inset-x-0 bottom-full mb-2 rounded-2xl p-3"
|
||||
style={GLASS}
|
||||
>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{GRADE_BANDS.map((band) => (
|
||||
<li key={band.key} className="flex items-center gap-2 text-content-secondary" style={fs(11)}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Mountain, Plus, Ruler, type LucideIcon } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Info, Mountain, Plus, Ruler, type LucideIcon } from 'lucide-react'
|
||||
import type { Day, DistanceUnit } from '../../types'
|
||||
import type { RouteGeometryAnalysis } from '../../utils/routeGeometry'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -159,6 +159,20 @@ export function TourDayMenu({ days, placeId, onPick, disabled = false, label, va
|
||||
)
|
||||
}
|
||||
|
||||
/** The round info dot beside a label; hover, focus and a tap show the explanation as a tooltip. */
|
||||
export function InfoDot({ description, label }: { description: string; label: string }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<Tooltip label={description}>
|
||||
<button type="button" aria-label={label} aria-describedby={id}
|
||||
className="grid h-5 w-5 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
|
||||
<Info size={13} strokeWidth={2} aria-hidden="true" />
|
||||
<span id={id} className="sr-only">{description}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** A labelled block of a tour panel: the eyebrow, an optional control on its right, then the content. */
|
||||
export function TourSection({ label, action, children, className = '' }: { label: ReactNode; action?: ReactNode; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
@@ -197,6 +211,42 @@ export function FoldButton({ expanded, onToggle, controls, label }: { expanded:
|
||||
)
|
||||
}
|
||||
|
||||
interface TourMetricField {
|
||||
label: string
|
||||
/** Said instead of `label` where the field is too narrow for it, rather than cutting it off. */
|
||||
shortLabel?: string
|
||||
value: string
|
||||
info?: { label: string; description: string }
|
||||
}
|
||||
|
||||
/** A label that falls back to its short form when the full one does not fit its box. */
|
||||
function FitLabel({ full, short }: { full: string; short?: string }) {
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const [narrow, setNarrow] = useState(false)
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current?.parentElement
|
||||
if (!element || !short) return
|
||||
// Measured with the full text in place: it fits when it does not overflow its box.
|
||||
const check = () => {
|
||||
const probe = ref.current
|
||||
if (!probe) return
|
||||
setNarrow(probe.scrollWidth > element.clientWidth + 0.5)
|
||||
}
|
||||
check()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(check)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [full, short])
|
||||
if (!short) return <>{full}</>
|
||||
return (
|
||||
<>
|
||||
<span ref={ref} aria-hidden={narrow || undefined} className={`inline-block whitespace-nowrap ${narrow ? 'invisible absolute' : ''}`}>{full}</span>
|
||||
{narrow && <span title={full}>{short}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tour's numbers as labelled fields, the way a booking card shows its facts:
|
||||
* distance, any extra the caller has (the walking time), then the altitudes.
|
||||
@@ -204,12 +254,12 @@ export function FoldButton({ expanded, onToggle, controls, label }: { expanded:
|
||||
export function TourMetricFields({ analysis, unit, extra = [], columns }: {
|
||||
analysis: Pick<RouteGeometryAnalysis, 'distanceKm' | 'minEle' | 'maxEle' | 'gain' | 'loss'> | null
|
||||
unit?: DistanceUnit
|
||||
extra?: Array<{ label: string; value: string }>
|
||||
extra?: Array<TourMetricField>
|
||||
/** A fixed column count for a narrow rail; the inspector lets the fields flow. */
|
||||
columns?: 2
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const fields: Array<{ label: string; value: string }> = [
|
||||
const fields: TourMetricField[] = [
|
||||
{ label: t('tours.detail.distance'), value: analysis ? formatDistance(analysis.distanceKm, unit) : '-' },
|
||||
...extra,
|
||||
]
|
||||
@@ -222,7 +272,10 @@ export function TourMetricFields({ analysis, unit, extra = [], columns }: {
|
||||
<dl className={`m-0 grid gap-2 ${grid}`}>
|
||||
{fields.map(field => (
|
||||
<div key={field.label} className="min-w-0">
|
||||
<dt><Eyebrow className="mb-[3px] truncate">{field.label}</Eyebrow></dt>
|
||||
<dt className="mb-[3px] flex min-w-0 items-center gap-0.5">
|
||||
<Eyebrow className="relative min-w-0 truncate"><FitLabel full={field.label} short={field.shortLabel} /></Eyebrow>
|
||||
{field.info && <span className="-my-1 flex-none"><InfoDot label={field.info.label} description={field.info.description} /></span>}
|
||||
</dt>
|
||||
<dd className={`${BOX} m-0 truncate px-[10px] py-[7px] text-center font-semibold tabular-nums text-content`} style={fs(12.5, 'body')}>{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -93,9 +93,8 @@ describe('Tours English and German copy', () => {
|
||||
})} />)
|
||||
expect(screen.getByRole('heading', { name: newTitle })).toBeInTheDocument()
|
||||
const strings = language === 'de' ? de : en
|
||||
expect(screen.getByText(toursText(strings, 'tours.planner.restoredShort'))).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(toursText(strings, 'tours.planner.restored'))).toBeInTheDocument()
|
||||
expect(screen.getByText(toursText(strings, 'tours.planner.status.routing'))).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(toursText(strings, 'tours.planner.status.routing'))).toBeInTheDocument()
|
||||
expect(screen.getByText(toursText(strings, 'tours.planner.difficulty.t3Warning'))).toBeInTheDocument()
|
||||
expect(screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent?.includes(toursText(strings, 'tours.planner.safetyNote')) === true)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(language === 'de' ? 'Wegpunkt 1' : 'Waypoint 1')).toBeInTheDocument()
|
||||
@@ -149,7 +148,7 @@ describe('Tours English and German copy', () => {
|
||||
assertNoRawToursKeys()
|
||||
}
|
||||
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status: 'ready', error: 'save' })} />)
|
||||
expect(screen.getByText(toursText(de, 'tours.planner.status.saveFailed'))).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(toursText(de, 'tours.planner.status.saveFailed'))).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: toursText(de, 'tours.planner.maxDifficulty') }))
|
||||
fireEvent.click(screen.getByRole('button', { name: toursText(de, 'tours.planner.difficulty.t4') }))
|
||||
expect(screen.getByRole('heading', { name: toursText(de, 'tours.planner.difficulty.alpineTitle') })).toBeInTheDocument()
|
||||
|
||||
Reference in New Issue
Block a user