mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6741e8ca0d | |||
| c14f179e39 | |||
| 3bfef73a46 | |||
| 7e790c3fb7 | |||
| 228db8541c | |||
| a729a6935f | |||
| 6c0e39e287 | |||
| 22574a2270 |
@@ -30,7 +30,7 @@
|
|||||||
"no-useless-escape": 1
|
"no-useless-escape": 1
|
||||||
},
|
},
|
||||||
"suppressed": {
|
"suppressed": {
|
||||||
"@typescript-eslint/no-explicit-any": 40,
|
"@typescript-eslint/no-explicit-any": 38,
|
||||||
"react-hooks/exhaustive-deps": 46
|
"react-hooks/exhaustive-deps": 46
|
||||||
},
|
},
|
||||||
"tests": {
|
"tests": {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||||
"src/components/Map/MapView.tsx": 1385,
|
"src/components/Map/MapView.tsx": 1385,
|
||||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||||
"src/components/Map/MapViewGL.tsx": 2290,
|
"src/components/Map/MapViewGL.tsx": 2218,
|
||||||
"src/components/Map/RouteCalculator.ts": 1063,
|
"src/components/Map/RouteCalculator.ts": 1063,
|
||||||
"src/components/PDF/TripPDF.tsx": 1082,
|
"src/components/PDF/TripPDF.tsx": 1082,
|
||||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||||
|
|||||||
@@ -102,16 +102,20 @@ export default function HelpPanel(): React.ReactElement | null {
|
|||||||
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
|
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
|
||||||
|
|
||||||
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
|
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
|
||||||
<button
|
{/* The close button sits in a row of its own above the scrolling content rather
|
||||||
type="button"
|
than floating over it, so no card ever runs up against it. */}
|
||||||
onClick={closeHelp}
|
<div className="flex shrink-0 justify-end px-4 pt-4">
|
||||||
aria-label={t('help.center.close')}
|
<button
|
||||||
className="absolute top-4 end-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
|
type="button"
|
||||||
>
|
onClick={closeHelp}
|
||||||
<X className="w-4 h-4" />
|
aria-label={t('help.center.close')}
|
||||||
</button>
|
className="w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
|
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
|
||||||
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
|
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 pt-4 pb-8">
|
||||||
{searching ? (
|
{searching ? (
|
||||||
<HelpSearchResults key="search" />
|
<HelpSearchResults key="search" />
|
||||||
) : guide ? (
|
) : guide ? (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-007
|
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-008
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { render, screen } from '../../../tests/helpers/render'
|
import { render, screen } from '../../../tests/helpers/render'
|
||||||
@@ -119,3 +119,28 @@ describe('DatePicker week start (#2029)', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('DatePicker inside a clipping dialog (#2858)', () => {
|
||||||
|
it('FE-COMP-JOURNEYDATEPICKER-008: the calendar floats over the page, stays inside the viewport and closes on a click away', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<div style={{ overflow: 'hidden' }}>
|
||||||
|
<DatePicker value="2026-03-15" onChange={vi.fn()} />
|
||||||
|
</div>,
|
||||||
|
)
|
||||||
|
const trigger = screen.getByText(/Mar 15, 2026/).closest('button') as HTMLButtonElement
|
||||||
|
// A trigger at the right edge, where the end date of a range sits.
|
||||||
|
trigger.getBoundingClientRect = () => ({ top: 100, bottom: 136, left: window.innerWidth - 120, right: window.innerWidth - 8, width: 112, height: 36, x: 0, y: 0, toJSON: () => ({}) })
|
||||||
|
await user.click(trigger)
|
||||||
|
|
||||||
|
const day31 = screen.getByRole('button', { name: '31' })
|
||||||
|
// Rendered outside the dialog that would clip it.
|
||||||
|
expect(container.contains(day31)).toBe(false)
|
||||||
|
const panel = day31.closest('.fixed') as HTMLElement
|
||||||
|
expect(panel.style.top).toBe('140px')
|
||||||
|
expect(parseFloat(panel.style.left)).toBe(window.innerWidth - 280 - 8)
|
||||||
|
|
||||||
|
await user.click(document.querySelector('[role="presentation"]') as HTMLElement)
|
||||||
|
expect(screen.queryByRole('button', { name: '31' })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { localIsoDate } from '../../utils/localDate'
|
import { localIsoDate } from '../../utils/localDate'
|
||||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useWeekStartDay } from '../../hooks/useWeekStartDay'
|
import { useWeekStartDay } from '../../hooks/useWeekStartDay'
|
||||||
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
|
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
|
||||||
|
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The header steps up a level on each click, days → months → years, and a
|
* The header steps up a level on each click, days → months → years, and a
|
||||||
@@ -14,6 +16,9 @@ import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
|
|||||||
*/
|
*/
|
||||||
type CalendarView = 'days' | 'months' | 'years'
|
type CalendarView = 'days' | 'months' | 'years'
|
||||||
const YEAR_PAGE_SIZE = 12
|
const YEAR_PAGE_SIZE = 12
|
||||||
|
/** The calendar's width; `w-[280px]` below. */
|
||||||
|
const PANEL_WIDTH = 280
|
||||||
|
const VIEWPORT_PAD = 8
|
||||||
|
|
||||||
export function DatePicker({ value, onChange, tripDates }: {
|
export function DatePicker({ value, onChange, tripDates }: {
|
||||||
value: string
|
value: string
|
||||||
@@ -23,6 +28,13 @@ export function DatePicker({ value, onChange, tripDates }: {
|
|||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const weekStart = useWeekStartDay()
|
const weekStart = useWeekStartDay()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
// The calendar used to hang absolutely under its trigger, inside dialogs that clip
|
||||||
|
// their content: the photo picker cut off the last week and, for the end date in
|
||||||
|
// the right-hand column, the last weekday (#2858). It now floats over the page,
|
||||||
|
// pinned to the trigger, flipped above it when below is too short and kept inside
|
||||||
|
// the viewport sideways.
|
||||||
|
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 340, matchWidth: false })
|
||||||
const [view, setView] = useState<CalendarView>('days')
|
const [view, setView] = useState<CalendarView>('days')
|
||||||
const [yearPageStart, setYearPageStart] = useState(0)
|
const [yearPageStart, setYearPageStart] = useState(0)
|
||||||
const [viewMonth, setViewMonth] = useState(() => {
|
const [viewMonth, setViewMonth] = useState(() => {
|
||||||
@@ -107,6 +119,7 @@ export function DatePicker({ value, onChange, tripDates }: {
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleOpen}
|
onClick={toggleOpen}
|
||||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-start flex items-center justify-between"
|
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-start flex items-center justify-between"
|
||||||
@@ -122,12 +135,20 @@ export function DatePicker({ value, onChange, tripDates }: {
|
|||||||
<Calendar size={13} className="text-zinc-400" />
|
<Calendar size={13} className="text-zinc-400" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && anchored && createPortal(
|
||||||
<>
|
<>
|
||||||
{/* Click-away catcher — no semantics of its own; the trigger button
|
{/* Click-away catcher — no semantics of its own; the trigger button
|
||||||
above closes the popover again from the keyboard. */}
|
above closes the popover again from the keyboard. */}
|
||||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
<div role="presentation" className="fixed inset-0 z-[var(--z-toast)]" onClick={() => setOpen(false)} />
|
||||||
<div className="absolute top-full start-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
<div
|
||||||
|
className="fixed z-[var(--z-toast)] overflow-y-auto bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]"
|
||||||
|
style={{
|
||||||
|
top: anchored.top,
|
||||||
|
bottom: anchored.bottom,
|
||||||
|
left: Math.max(VIEWPORT_PAD, Math.min(anchored.left, window.innerWidth - PANEL_WIDTH - VIEWPORT_PAD)),
|
||||||
|
maxHeight: anchored.maxHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
||||||
@@ -200,7 +221,7 @@ export function DatePicker({ value, onChange, tripDates }: {
|
|||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
{isTrip && !isSelected && (
|
{isTrip && !isSelected && (
|
||||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
<span className="absolute bottom-1 inset-x-0 mx-auto w-1 h-1 rounded-full bg-indigo-500" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -209,7 +230,8 @@ export function DatePicker({ value, onChange, tripDates }: {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -375,11 +375,12 @@ describe('ProviderPicker', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Search' }))
|
await user.click(screen.getByRole('button', { name: 'Search' }))
|
||||||
expect(ranges).toHaveLength(0)
|
expect(ranges).toHaveLength(0)
|
||||||
|
|
||||||
|
// The calendar opens in a portal, so its days are looked up on the page, not under the trigger.
|
||||||
const [fromPicker, toPicker] = Array.from(baseElement.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
|
const [fromPicker, toPicker] = Array.from(baseElement.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
|
||||||
await user.click(within(fromPicker).getByRole('button'))
|
await user.click(within(fromPicker).getByRole('button'))
|
||||||
await user.click(within(fromPicker).getByRole('button', { name: '3' }))
|
await user.click(screen.getByRole('button', { name: '3' }))
|
||||||
await user.click(within(toPicker).getByRole('button'))
|
await user.click(within(toPicker).getByRole('button'))
|
||||||
await user.click(within(toPicker).getByRole('button', { name: '9' }))
|
await user.click(screen.getByRole('button', { name: '9' }))
|
||||||
await user.click(screen.getByRole('button', { name: 'Search' }))
|
await user.click(screen.getByRole('button', { name: 'Search' }))
|
||||||
|
|
||||||
await waitFor(() => expect(ranges).toHaveLength(1))
|
await waitFor(() => expect(ranges).toHaveLength(1))
|
||||||
|
|||||||
@@ -50,16 +50,10 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
|
|||||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||||
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
|
import {
|
||||||
|
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
|
||||||
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
|
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
|
||||||
// HTML photo markers *and* cluster them natively, so we feed the place points
|
} from './placeClusterLayers'
|
||||||
// into a clustered GeoJSON source. The cluster bubbles render as GL circles +
|
|
||||||
// a count label; the individual rich HTML markers are then only drawn for the
|
|
||||||
// points the source reports as currently unclustered. Grouping is always on,
|
|
||||||
// matching the Leaflet map's MarkerClusterGroup.
|
|
||||||
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
|
|
||||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
|
|
||||||
|
|
||||||
type PlaceWithCoords = Place & { lat: number; lng: number }
|
type PlaceWithCoords = Place & { lat: number; lng: number }
|
||||||
|
|
||||||
@@ -112,72 +106,6 @@ interface Props extends MapViewProps {
|
|||||||
gl: any
|
gl: any
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How eagerly place markers merge into a cluster.
|
|
||||||
*
|
|
||||||
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
|
|
||||||
* kept as plain numbers rather than shared constants so the GL bundle does not have to
|
|
||||||
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
|
|
||||||
* own, and the stops that share a coordinate are folded together at the pin level
|
|
||||||
* instead — see the reconcile below.
|
|
||||||
*/
|
|
||||||
const CLUSTER_RADIUS = 20
|
|
||||||
const CLUSTER_MAX_ZOOM = 8
|
|
||||||
|
|
||||||
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
|
||||||
* change and the initial build cannot drift apart. */
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function addPlaceClusterLayers(map: any): void {
|
|
||||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
|
||||||
type: 'geojson',
|
|
||||||
data: { type: 'FeatureCollection', features: [] },
|
|
||||||
cluster: true,
|
|
||||||
clusterRadius: CLUSTER_RADIUS,
|
|
||||||
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
|
||||||
})
|
|
||||||
map.addLayer({
|
|
||||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
|
||||||
type: 'circle',
|
|
||||||
source: PLACE_CLUSTER_SOURCE_ID,
|
|
||||||
filter: ['has', 'point_count'],
|
|
||||||
paint: {
|
|
||||||
'circle-color': '#111827',
|
|
||||||
'circle-opacity': 0.97,
|
|
||||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
|
||||||
'circle-stroke-width': 2.5,
|
|
||||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
map.addLayer({
|
|
||||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
|
||||||
type: 'symbol',
|
|
||||||
source: PLACE_CLUSTER_SOURCE_ID,
|
|
||||||
filter: ['has', 'point_count'],
|
|
||||||
layout: {
|
|
||||||
'text-field': ['get', 'point_count_abbreviated'],
|
|
||||||
'text-size': 12,
|
|
||||||
'text-allow-overlap': true,
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
'text-color': '#ffffff',
|
|
||||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
|
||||||
'text-halo-width': 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
map.addLayer({
|
|
||||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
|
||||||
type: 'circle',
|
|
||||||
source: PLACE_CLUSTER_SOURCE_ID,
|
|
||||||
filter: ['!', ['has', 'point_count']],
|
|
||||||
paint: {
|
|
||||||
'circle-radius': 24,
|
|
||||||
'circle-opacity': 0,
|
|
||||||
'circle-stroke-opacity': 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||||
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
|
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
|
||||||
@@ -612,6 +540,8 @@ export function MapViewGL({
|
|||||||
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||||
|
/** The count bubbles, one per cluster the source currently reports. */
|
||||||
|
const clusterPinsRef = useRef<PlacePin[]>([])
|
||||||
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
||||||
const pinLayerRef = useRef<HTMLDivElement | null>(null)
|
const pinLayerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||||
@@ -642,6 +572,7 @@ export function MapViewGL({
|
|||||||
// in the same frame the canvas draws, or the ones left out swim against the ones in.
|
// in the same frame the canvas draws, or the ones left out swim against the ones in.
|
||||||
const repositionPins = useCallback(() => {
|
const repositionPins = useCallback(() => {
|
||||||
markersRef.current.forEach(pin => pin.reposition())
|
markersRef.current.forEach(pin => pin.reposition())
|
||||||
|
clusterPinsRef.current.forEach(pin => pin.reposition())
|
||||||
poiMarkersRef.current.forEach(pin => pin.reposition())
|
poiMarkersRef.current.forEach(pin => pin.reposition())
|
||||||
pluginMarkersRef.current.forEach(pin => pin.reposition())
|
pluginMarkersRef.current.forEach(pin => pin.reposition())
|
||||||
routeViaMarkersRef.current.forEach(pin => pin.reposition())
|
routeViaMarkersRef.current.forEach(pin => pin.reposition())
|
||||||
@@ -1304,21 +1235,7 @@ export function MapViewGL({
|
|||||||
? map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] })
|
? map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] })
|
||||||
: []
|
: []
|
||||||
const feature = features?.[0]
|
const feature = features?.[0]
|
||||||
const clusterId = feature?.properties?.cluster_id
|
expandCluster(map, feature?.properties?.cluster_id, feature?.geometry?.coordinates)
|
||||||
const coordinates = feature?.geometry?.coordinates
|
|
||||||
if (clusterId == null || !Array.isArray(coordinates)) return
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as any
|
|
||||||
const easeToZoom = (nextZoom: number) => {
|
|
||||||
try { map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 }) } catch { /* noop */ }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err: Error | null, nextZoom: number) => {
|
|
||||||
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom)
|
|
||||||
})
|
|
||||||
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom)
|
|
||||||
else if (maybeZoom && typeof maybeZoom.then === 'function') maybeZoom.then(easeToZoom).catch(() => {})
|
|
||||||
} catch { /* noop */ }
|
|
||||||
}
|
}
|
||||||
const setClusterCursor = () => {
|
const setClusterCursor = () => {
|
||||||
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
|
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
|
||||||
@@ -1704,8 +1621,17 @@ export function MapViewGL({
|
|||||||
source.setData(buildPlaceClusterData(places) as any)
|
source.setData(buildPlaceClusterData(places) as any)
|
||||||
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
||||||
let raf: number | null = null
|
let raf: number | null = null
|
||||||
|
const dropClusterPins = () => {
|
||||||
|
clusterPinsRef.current.forEach(pin => pin.remove())
|
||||||
|
clusterPinsRef.current = []
|
||||||
|
}
|
||||||
|
const reconcileClusterPins = () => {
|
||||||
|
dropClusterPins()
|
||||||
|
clusterPinsRef.current = drawClusterBubbles(map, (el, lng, lat) => attachPin(map, gl, pinLayerRef.current, el, lng, lat))
|
||||||
|
}
|
||||||
const runReconcile = () => {
|
const runReconcile = () => {
|
||||||
raf = null
|
raf = null
|
||||||
|
reconcileClusterPins()
|
||||||
const features = map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['!', ['has', 'point_count']] }) || []
|
const features = map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['!', ['has', 'point_count']] }) || []
|
||||||
const seen = new Set<number>()
|
const seen = new Set<number>()
|
||||||
const visiblePlaces: PlaceWithCoords[] = []
|
const visiblePlaces: PlaceWithCoords[] = []
|
||||||
@@ -1752,6 +1678,7 @@ export function MapViewGL({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (raf !== null) cancelAnimationFrame(raf)
|
if (raf !== null) cancelAnimationFrame(raf)
|
||||||
|
dropClusterPins()
|
||||||
map.off('moveend', scheduleReconcile)
|
map.off('moveend', scheduleReconcile)
|
||||||
map.off('zoomend', scheduleReconcile)
|
map.off('zoomend', scheduleReconcile)
|
||||||
map.off('idle', scheduleReconcile)
|
map.off('idle', scheduleReconcile)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { STACK_RADIUS_PX } from './coincidentPlaces'
|
import { STACK_RADIUS_PX } from './coincidentPlaces'
|
||||||
|
import { clusterBubbleSize } from './placeClusterLayers'
|
||||||
|
|
||||||
/** Up to here the map is an overview, where stops merge to keep the world readable. */
|
/** Up to here the map is an overview, where stops merge to keep the world readable. */
|
||||||
export const CLUSTER_UNTIL_ZOOM = 9
|
export const CLUSTER_UNTIL_ZOOM = 9
|
||||||
@@ -66,7 +67,7 @@ export interface ClusterLike {
|
|||||||
/** A count bubble sized by its count, styled by `.marker-cluster-custom` in index.css. */
|
/** A count bubble sized by its count, styled by `.marker-cluster-custom` in index.css. */
|
||||||
export function createClusterIcon(cluster: ClusterLike) {
|
export function createClusterIcon(cluster: ClusterLike) {
|
||||||
const count = cluster.getChildCount()
|
const count = cluster.getChildCount()
|
||||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
const size = clusterBubbleSize(count)
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
|
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
|
||||||
className: 'marker-cluster-wrapper',
|
className: 'marker-cluster-wrapper',
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ClusterMap,
|
||||||
|
PLACE_CLUSTER_SOURCE_ID,
|
||||||
|
addPlaceClusterLayers,
|
||||||
|
clusterBubbleSize,
|
||||||
|
createClusterBubble,
|
||||||
|
drawClusterBubbles,
|
||||||
|
} from './placeClusterLayers';
|
||||||
|
|
||||||
|
function fakeMap(features: unknown[], expansionZoom: unknown = 11) {
|
||||||
|
const source = { getClusterExpansionZoom: vi.fn(() => expansionZoom) };
|
||||||
|
const map = {
|
||||||
|
addSource: vi.fn(),
|
||||||
|
addLayer: vi.fn(),
|
||||||
|
getSource: vi.fn((id: string) => (id === PLACE_CLUSTER_SOURCE_ID ? source : undefined)),
|
||||||
|
easeTo: vi.fn(),
|
||||||
|
querySourceFeatures: vi.fn((_id: string, query: { filter: unknown[] }) =>
|
||||||
|
query.filter[0] === 'has' ? features : []
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return { map: map as unknown as ClusterMap & typeof map, source };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cluster bubbles', () => {
|
||||||
|
it('FE-COMP-PLACECLUSTER-001: a bubble grows in three steps with its count', () => {
|
||||||
|
expect([1, 9, 10, 49, 50, 500].map(clusterBubbleSize)).toEqual([36, 36, 42, 42, 48, 48]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PLACECLUSTER-002: the GL bubble is the Leaflet markup inside a bare wrapper the pin can move', () => {
|
||||||
|
const wrapper = createClusterBubble(38);
|
||||||
|
expect(wrapper.className).toBe('');
|
||||||
|
const bubble = wrapper.firstElementChild as HTMLElement;
|
||||||
|
expect(bubble.className).toBe('marker-cluster-custom');
|
||||||
|
expect(bubble.style.width).toBe('42px');
|
||||||
|
expect(bubble.style.height).toBe('42px');
|
||||||
|
expect(bubble.textContent).toBe('38');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PLACECLUSTER-003: the canvas circle and count stay as an invisible hit area', () => {
|
||||||
|
const { map } = fakeMap([]);
|
||||||
|
addPlaceClusterLayers(map);
|
||||||
|
expect(map.addSource).toHaveBeenCalledWith(PLACE_CLUSTER_SOURCE_ID, expect.objectContaining({ cluster: true }));
|
||||||
|
const layers = map.addLayer.mock.calls.map(([layer]) => layer as { id: string; paint: Record<string, unknown> });
|
||||||
|
expect(layers.find((l) => l.id === 'trip-place-clusters-circle')?.paint['circle-opacity']).toBe(0);
|
||||||
|
expect(layers.find((l) => l.id === 'trip-place-clusters-count')?.paint['text-opacity']).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PLACECLUSTER-004: one bubble per cluster, placed at its centre, that zooms in on a click', () => {
|
||||||
|
const cluster = { properties: { cluster_id: 5, point_count: 12 }, geometry: { coordinates: [139.7, 35.6] } };
|
||||||
|
const { map, source } = fakeMap([
|
||||||
|
cluster,
|
||||||
|
cluster, // the same cluster crossing a tile edge
|
||||||
|
{ properties: { cluster_id: 6 }, geometry: { coordinates: [1, 1] } }, // no count
|
||||||
|
{ properties: { cluster_id: 7, point_count: 3 } }, // no geometry
|
||||||
|
]);
|
||||||
|
const placed: { el: HTMLElement; lng: number; lat: number }[] = [];
|
||||||
|
const pins = drawClusterBubbles(map, (el, lng, lat) => {
|
||||||
|
placed.push({ el, lng, lat });
|
||||||
|
return lng;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pins).toEqual([139.7]);
|
||||||
|
expect(placed[0].el.textContent).toBe('12');
|
||||||
|
expect(placed[0].lat).toBe(35.6);
|
||||||
|
|
||||||
|
const onMapClick = vi.fn();
|
||||||
|
document.body.appendChild(placed[0].el);
|
||||||
|
document.body.addEventListener('click', onMapClick);
|
||||||
|
placed[0].el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
document.body.removeEventListener('click', onMapClick);
|
||||||
|
placed[0].el.remove();
|
||||||
|
|
||||||
|
// The click is the bubble's: the map underneath never sees it.
|
||||||
|
expect(onMapClick).not.toHaveBeenCalled();
|
||||||
|
expect(source.getClusterExpansionZoom).toHaveBeenCalledWith(5, expect.any(Function));
|
||||||
|
expect(map.easeTo).toHaveBeenCalledWith({ center: [139.7, 35.6], zoom: 11, duration: 350 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PLACECLUSTER-005: a source that reports nothing draws nothing', () => {
|
||||||
|
const { map } = fakeMap([]);
|
||||||
|
map.querySourceFeatures.mockReturnValue(null as never);
|
||||||
|
expect(drawClusterBubbles(map, () => 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,3 +5,169 @@
|
|||||||
*/
|
*/
|
||||||
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
|
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
|
||||||
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
|
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
|
||||||
|
|
||||||
|
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
|
||||||
|
// HTML photo markers *and* cluster them natively, so we feed the place points
|
||||||
|
// into a clustered GeoJSON source. The source decides what is clustered; the
|
||||||
|
// bubbles are drawn as DOM (`drawClusterBubbles`) and the individual rich HTML
|
||||||
|
// markers only for the points the source reports as currently unclustered.
|
||||||
|
// Grouping is always on, matching the Leaflet map's MarkerClusterGroup.
|
||||||
|
export const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters';
|
||||||
|
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How eagerly place markers merge into a cluster.
|
||||||
|
*
|
||||||
|
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
|
||||||
|
* kept as plain numbers rather than shared constants so the GL bundle does not have to
|
||||||
|
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
|
||||||
|
* own, and the stops that share a coordinate are folded together at the pin level
|
||||||
|
* instead — see the reconcile in MapViewGL.
|
||||||
|
*/
|
||||||
|
const CLUSTER_RADIUS = 20;
|
||||||
|
const CLUSTER_MAX_ZOOM = 8;
|
||||||
|
|
||||||
|
/** The part of either SDK's map the cluster code calls. */
|
||||||
|
export interface ClusterMap {
|
||||||
|
addSource: (id: string, source: object) => void;
|
||||||
|
addLayer: (layer: object) => void;
|
||||||
|
getSource: (id: string) => unknown;
|
||||||
|
easeTo: (options: { center: unknown[]; zoom: number; duration: number }) => void;
|
||||||
|
querySourceFeatures: (id: string, options: { filter: unknown[] }) => ClusterFeature[] | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClusterFeature {
|
||||||
|
properties?: { cluster_id?: unknown; point_count?: unknown } | null;
|
||||||
|
geometry?: { coordinates?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClusterSource {
|
||||||
|
getClusterExpansionZoom?: (id: unknown, done: (err: Error | null, zoom: number) => void) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
||||||
|
* change and the initial build cannot drift apart. */
|
||||||
|
export function addPlaceClusterLayers(map: Pick<ClusterMap, 'addSource' | 'addLayer'>): void {
|
||||||
|
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'FeatureCollection', features: [] },
|
||||||
|
cluster: true,
|
||||||
|
clusterRadius: CLUSTER_RADIUS,
|
||||||
|
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
||||||
|
});
|
||||||
|
// Invisible: the bubble the user sees is DOM (createClusterBubble), these two
|
||||||
|
// layers stay as the hit area the track and route layers check a click against.
|
||||||
|
map.addLayer({
|
||||||
|
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||||
|
type: 'circle',
|
||||||
|
source: PLACE_CLUSTER_SOURCE_ID,
|
||||||
|
filter: ['has', 'point_count'],
|
||||||
|
paint: {
|
||||||
|
'circle-opacity': 0,
|
||||||
|
'circle-stroke-opacity': 0,
|
||||||
|
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||||
|
type: 'symbol',
|
||||||
|
source: PLACE_CLUSTER_SOURCE_ID,
|
||||||
|
filter: ['has', 'point_count'],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'point_count_abbreviated'],
|
||||||
|
'text-size': 12,
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
},
|
||||||
|
paint: { 'text-opacity': 0 },
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||||
|
type: 'circle',
|
||||||
|
source: PLACE_CLUSTER_SOURCE_ID,
|
||||||
|
filter: ['!', ['has', 'point_count']],
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 24,
|
||||||
|
'circle-opacity': 0,
|
||||||
|
'circle-stroke-opacity': 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eases the map in to the zoom a cluster falls apart at. Both SDKs answer that
|
||||||
|
* question, one through a callback and one through a promise. */
|
||||||
|
export function expandCluster(
|
||||||
|
map: Pick<ClusterMap, 'getSource' | 'easeTo'>,
|
||||||
|
clusterId: unknown,
|
||||||
|
coordinates: unknown
|
||||||
|
): void {
|
||||||
|
if (clusterId == null || !Array.isArray(coordinates)) return;
|
||||||
|
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as ClusterSource | undefined;
|
||||||
|
const easeToZoom = (nextZoom: number) => {
|
||||||
|
try {
|
||||||
|
map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 });
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err, nextZoom) => {
|
||||||
|
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom);
|
||||||
|
});
|
||||||
|
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom);
|
||||||
|
else if (maybeZoom instanceof Promise) maybeZoom.then(easeToZoom).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts one count bubble on the map per cluster the source currently reports, through
|
||||||
|
* `place` (the caller's pin factory), and returns what `place` handed back. A cluster
|
||||||
|
* crossing a tile edge comes back once per tile, so bubbles are keyed by cluster id.
|
||||||
|
*/
|
||||||
|
export function drawClusterBubbles<T>(map: ClusterMap, place: (el: HTMLElement, lng: number, lat: number) => T): T[] {
|
||||||
|
const pins: T[] = [];
|
||||||
|
const seen = new Set<unknown>();
|
||||||
|
for (const feature of map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['has', 'point_count'] }) ?? []) {
|
||||||
|
const clusterId = feature?.properties?.cluster_id;
|
||||||
|
const count = feature?.properties?.point_count;
|
||||||
|
const coordinates = feature?.geometry?.coordinates;
|
||||||
|
if (typeof count !== 'number' || !Array.isArray(coordinates) || seen.has(clusterId)) continue;
|
||||||
|
seen.add(clusterId);
|
||||||
|
const el = createClusterBubble(count);
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
expandCluster(map, clusterId, coordinates);
|
||||||
|
});
|
||||||
|
pins.push(place(el, coordinates[0], coordinates[1]));
|
||||||
|
}
|
||||||
|
return pins;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A count bubble's diameter in pixels: both renderers size their bubbles by it. */
|
||||||
|
export function clusterBubbleSize(count: number): number {
|
||||||
|
return count < 10 ? 36 : count < 50 ? 42 : 48;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A count bubble as DOM, styled by `.marker-cluster-custom` in index.css.
|
||||||
|
*
|
||||||
|
* The GL map draws its bubbles with this element rather than as canvas text, so they
|
||||||
|
* read in the app's own font and get the same hover the Leaflet map has. A canvas
|
||||||
|
* symbol layer can only use the glyphs the map style ships and knows no `:hover`.
|
||||||
|
* The bubble sits in a bare wrapper because the pin positions its element through
|
||||||
|
* `transform`, which would otherwise override the hover's scale.
|
||||||
|
*/
|
||||||
|
export function createClusterBubble(count: number): HTMLDivElement {
|
||||||
|
const size = clusterBubbleSize(count);
|
||||||
|
const bubble = document.createElement('div');
|
||||||
|
bubble.className = 'marker-cluster-custom';
|
||||||
|
bubble.style.width = `${size}px`;
|
||||||
|
bubble.style.height = `${size}px`;
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = String(count);
|
||||||
|
bubble.appendChild(label);
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.appendChild(bubble);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|||||||
@@ -138,6 +138,34 @@ describe('TransitSearchPanel', () => {
|
|||||||
expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
|
expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSIT-003b: routes Google answered name Google Maps and are saved as Google’s (#2850)', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onAdd = vi.fn().mockResolvedValue({})
|
||||||
|
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'google' })
|
||||||
|
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
|
||||||
|
await pickFromAndTo(user)
|
||||||
|
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||||
|
await user.click(await screen.findByText(/08:30 – 09:00/))
|
||||||
|
|
||||||
|
// Google's terms want the words as written, so the browser must not translate them.
|
||||||
|
expect(screen.getByText('Google Maps')).toHaveAttribute('translate', 'no')
|
||||||
|
expect(screen.queryByRole('link', { name: 'Transitous' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
|
||||||
|
await waitFor(() => expect(onAdd).toHaveBeenCalled())
|
||||||
|
expect(onAdd.mock.calls[0][0].metadata.transit.provider).toBe('google')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSIT-003c: routes Transitous answered credit Transitous with its sources link', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'transitous' })
|
||||||
|
render(<TransitSearchPanel {...makeProps()} />)
|
||||||
|
await pickFromAndTo(user)
|
||||||
|
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||||
|
expect(await screen.findByRole('link', { name: 'Transitous' })).toHaveAttribute('href', 'https://transitous.org/sources/')
|
||||||
|
expect(screen.queryByText('Google Maps')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
|
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
|
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
|
||||||
|
|||||||
@@ -604,7 +604,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
|||||||
notes: null,
|
notes: null,
|
||||||
metadata: {
|
metadata: {
|
||||||
transit: {
|
transit: {
|
||||||
provider: 'transitous',
|
provider: provider ?? 'transitous',
|
||||||
duration: it.duration,
|
duration: it.duration,
|
||||||
transfers: it.transfers,
|
transfers: it.transfers,
|
||||||
walk_seconds: it.walkSeconds,
|
walk_seconds: it.walkSeconds,
|
||||||
@@ -760,7 +760,10 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
|||||||
))}
|
))}
|
||||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', textAlign: 'center', marginTop: 2 }}>
|
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', textAlign: 'center', marginTop: 2 }}>
|
||||||
{t('transit.attribution')}{' '}
|
{t('transit.attribution')}{' '}
|
||||||
<a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>
|
{/* Named after the backend that answered; Google's terms want "Google Maps" as written, never translated. */}
|
||||||
|
{provider === 'google'
|
||||||
|
? <span translate="no">Google Maps</span>
|
||||||
|
: <a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -375,6 +375,12 @@ const aiProviderStateSchema = z.discriminatedUnion('adapter', [
|
|||||||
adapter: z.literal('anthropic-messages'),
|
adapter: z.literal('anthropic-messages'),
|
||||||
blocks: z.array(z.record(z.string(), z.unknown())),
|
blocks: z.array(z.record(z.string(), z.unknown())),
|
||||||
}),
|
}),
|
||||||
|
// Gemini's thought signature per tool call id, which its Chat Completions endpoint wants back with the call.
|
||||||
|
z.object({
|
||||||
|
adapter: z.literal('openai-chat'),
|
||||||
|
model: z.string(),
|
||||||
|
signatures: z.record(z.string(), z.string()),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
export type AiProviderState = z.infer<typeof aiProviderStateSchema>;
|
export type AiProviderState = z.infer<typeof aiProviderStateSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -26,16 +26,29 @@ import type { AiConnectionKind } from '@trek/shared';
|
|||||||
/** The end of an OpenAI-compatible stream. */
|
/** The end of an OpenAI-compatible stream. */
|
||||||
const DONE = '[DONE]';
|
const DONE = '[DONE]';
|
||||||
|
|
||||||
|
/** The host of Gemini's OpenAI-compatible endpoint, which an admin may also enter as their own server. */
|
||||||
|
const GEMINI_HOST = 'generativelanguage.googleapis.com';
|
||||||
|
|
||||||
|
function isGeminiHost(baseUrl: string): boolean {
|
||||||
|
try {
|
||||||
|
return new URL(baseUrl).hostname === GEMINI_HOST;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The provider-neutral history as Chat Completions messages, the instructions
|
* The provider-neutral history as Chat Completions messages, the instructions
|
||||||
* and the trip card first. A tool message takes no image, so the images of a
|
* and the trip card first. A tool message takes no image, so the images of a
|
||||||
* step's tool results follow its last result as one user message.
|
* step's tool results follow its last result as one user message. `model` is
|
||||||
|
* the one the request goes to: a thought signature goes back only to the model
|
||||||
|
* that signed it, since another one cannot read it.
|
||||||
*/
|
*/
|
||||||
export function toChatMessages(request: AiChatRequest): Record<string, unknown>[] {
|
export function toChatMessages(request: AiChatRequest, model?: string): Record<string, unknown>[] {
|
||||||
const system = [request.system, request.context].filter(Boolean).join('\n\n');
|
const system = [request.system, request.context].filter(Boolean).join('\n\n');
|
||||||
const out: Record<string, unknown>[] = [{ role: 'system', content: system }];
|
const out: Record<string, unknown>[] = [{ role: 'system', content: system }];
|
||||||
request.messages.forEach((message, index) => {
|
request.messages.forEach((message, index) => {
|
||||||
out.push(toChatMessage(message));
|
out.push(toChatMessage(message, model));
|
||||||
const withImages = toolImagesEndingAt(request.messages, index);
|
const withImages = toolImagesEndingAt(request.messages, index);
|
||||||
if (withImages.length === 0) return;
|
if (withImages.length === 0) return;
|
||||||
const content = withImages.flatMap((tool) => [
|
const content = withImages.flatMap((tool) => [
|
||||||
@@ -51,12 +64,14 @@ function imagePart(image: AiImage): Record<string, unknown> {
|
|||||||
return { type: 'image_url', image_url: { url: imageDataUrl(image), detail: image.detail ?? 'high' } };
|
return { type: 'image_url', image_url: { url: imageDataUrl(image), detail: image.detail ?? 'high' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toChatMessage(message: AiChatMessage): Record<string, unknown> {
|
function toChatMessage(message: AiChatMessage, model: string | undefined): Record<string, unknown> {
|
||||||
if (message.role === 'user') {
|
if (message.role === 'user') {
|
||||||
if (!message.images?.length) return { role: 'user', content: message.text };
|
if (!message.images?.length) return { role: 'user', content: message.text };
|
||||||
return { role: 'user', content: [{ type: 'text', text: message.text }, ...message.images.map(imagePart)] };
|
return { role: 'user', content: [{ type: 'text', text: message.text }, ...message.images.map(imagePart)] };
|
||||||
}
|
}
|
||||||
if (message.role === 'tool') return { role: 'tool', tool_call_id: message.toolCallId, content: message.output };
|
if (message.role === 'tool') return { role: 'tool', tool_call_id: message.toolCallId, content: message.output };
|
||||||
|
const state = message.providerState;
|
||||||
|
const signatures = state?.adapter === 'openai-chat' && state.model === model ? state.signatures : {};
|
||||||
return {
|
return {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: message.text || null,
|
content: message.text || null,
|
||||||
@@ -66,19 +81,32 @@ function toChatMessage(message: AiChatMessage): Record<string, unknown> {
|
|||||||
id: call.id,
|
id: call.id,
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: { name: call.name, arguments: JSON.stringify(call.args) },
|
function: { name: call.name, arguments: JSON.stringify(call.args) },
|
||||||
|
...(signatures[call.id] ? { extra_content: { google: { thought_signature: signatures[call.id] } } } : {}),
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gemini signs a tool call on its first piece; the call has to go back with it or the next request is refused. */
|
||||||
|
function thoughtSignatureOf(piece: Record<string, unknown>): string {
|
||||||
|
return asText(asRecord(asRecord(piece.extra_content).google).thought_signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The id a call goes on with; a provider that names none gets one from the call's position. */
|
||||||
|
function callId(index: number, id: string): string {
|
||||||
|
return id || `call_${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
const FINISH_REASONS: Record<string, AiFinishReason> = { tool_calls: 'tool-calls', length: 'length' };
|
const FINISH_REASONS: Record<string, AiFinishReason> = { tool_calls: 'tool-calls', length: 'length' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The OpenAI-compatible Chat Completions API, streamed: OpenAI itself, Gemini
|
* The OpenAI-compatible Chat Completions API, streamed: OpenAI itself, Gemini
|
||||||
* (its `/v1beta/openai` endpoint), OpenRouter and an admin's own server. The
|
* (its `/v1beta/openai` endpoint), OpenRouter and an admin's own server. The
|
||||||
* kind decides only what a request may carry: Gemini gets its narrower tool
|
* kind decides only what a request may carry: Gemini gets its narrower tool
|
||||||
* schemas, OpenAI the prompt cache key, which the others do not know.
|
* schemas, also when an admin enters its endpoint as their own server, and
|
||||||
|
* OpenAI the prompt cache key, which the others do not know. Gemini's thought
|
||||||
|
* signatures come back as the turn's provider state.
|
||||||
*/
|
*/
|
||||||
export class OpenAiChatClient implements AiChatClient {
|
export class OpenAiChatClient implements AiChatClient {
|
||||||
/** OpenRouter searches the web for any model through its web plugin; the others have no search here. */
|
/** OpenRouter searches the web for any model through its web plugin; the others have no search here. */
|
||||||
@@ -86,20 +114,25 @@ export class OpenAiChatClient implements AiChatClient {
|
|||||||
return this.kind === 'openrouter';
|
return this.kind === 'openrouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The kind whose tool schemas the endpoint takes. */
|
||||||
|
private readonly schemaKind: AiConnectionKind;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly kind: AiConnectionKind,
|
readonly kind: AiConnectionKind,
|
||||||
readonly model: string,
|
readonly model: string,
|
||||||
private readonly baseUrl: string,
|
private readonly baseUrl: string,
|
||||||
private readonly apiKey: string | null,
|
private readonly apiKey: string | null,
|
||||||
private readonly fetcher: AiFetch,
|
private readonly fetcher: AiFetch,
|
||||||
) {}
|
) {
|
||||||
|
this.schemaKind = kind === 'custom' && isGeminiHost(baseUrl) ? 'gemini' : kind;
|
||||||
|
}
|
||||||
|
|
||||||
async *stream(request: AiChatRequest, signal: AbortSignal): AsyncGenerator<AiChatEvent> {
|
async *stream(request: AiChatRequest, signal: AbortSignal): AsyncGenerator<AiChatEvent> {
|
||||||
const body = {
|
const body = {
|
||||||
model: this.model,
|
model: this.model,
|
||||||
stream: true,
|
stream: true,
|
||||||
stream_options: { include_usage: true },
|
stream_options: { include_usage: true },
|
||||||
messages: toChatMessages(request),
|
messages: toChatMessages(request, this.model),
|
||||||
...(request.tools.length > 0
|
...(request.tools.length > 0
|
||||||
? {
|
? {
|
||||||
tools: request.tools.map((tool) => ({
|
tools: request.tools.map((tool) => ({
|
||||||
@@ -107,7 +140,7 @@ export class OpenAiChatClient implements AiChatClient {
|
|||||||
function: {
|
function: {
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
parameters: toProviderSchema(tool.parameters, this.kind),
|
parameters: toProviderSchema(tool.parameters, this.schemaKind),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
tool_choice: 'auto',
|
tool_choice: 'auto',
|
||||||
@@ -153,10 +186,22 @@ export class OpenAiChatClient implements AiChatClient {
|
|||||||
cachedInputTokens: turn.cachedInputTokens,
|
cachedInputTokens: turn.cachedInputTokens,
|
||||||
outputTokens: turn.outputTokens,
|
outputTokens: turn.outputTokens,
|
||||||
};
|
};
|
||||||
|
const signatures = turn.signatures();
|
||||||
|
if (Object.keys(signatures).length > 0) {
|
||||||
|
yield { type: 'provider-state', state: { adapter: 'openai-chat', model: this.model, signatures } };
|
||||||
|
}
|
||||||
yield { type: 'finish', reason: turn.finishReason() };
|
yield { type: 'finish', reason: turn.finishReason() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A tool call while it streams in: its id, name and arguments so far, and Gemini's signature. */
|
||||||
|
interface PendingCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
args: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What one streamed Chat Completions turn has said so far. A tool call
|
* What one streamed Chat Completions turn has said so far. A tool call
|
||||||
* arrives in pieces keyed by its index (its id and name in the first, the
|
* arrives in pieces keyed by its index (its id and name in the first, the
|
||||||
@@ -168,7 +213,7 @@ class ChatTurn {
|
|||||||
outputTokens = 0;
|
outputTokens = 0;
|
||||||
reason: string | null = null;
|
reason: string | null = null;
|
||||||
done = false;
|
done = false;
|
||||||
private readonly pending = new Map<number, { id: string; name: string; args: string }>();
|
private readonly pending = new Map<number, PendingCall>();
|
||||||
private readonly cited = new Set<string>();
|
private readonly cited = new Set<string>();
|
||||||
private fresh: AiSource[] = [];
|
private fresh: AiSource[] = [];
|
||||||
|
|
||||||
@@ -199,10 +244,11 @@ class ChatTurn {
|
|||||||
const piece = asRecord(raw);
|
const piece = asRecord(raw);
|
||||||
const index = typeof piece.index === 'number' ? piece.index : position;
|
const index = typeof piece.index === 'number' ? piece.index : position;
|
||||||
const fn = asRecord(piece.function);
|
const fn = asRecord(piece.function);
|
||||||
const call = this.pending.get(index) ?? { id: '', name: '', args: '' };
|
const call = this.pending.get(index) ?? { id: '', name: '', args: '', signature: '' };
|
||||||
call.id ||= asText(piece.id);
|
call.id ||= asText(piece.id);
|
||||||
call.name ||= asText(fn.name);
|
call.name ||= asText(fn.name);
|
||||||
call.args += asText(fn.arguments);
|
call.args += asText(fn.arguments);
|
||||||
|
call.signature ||= thoughtSignatureOf(piece);
|
||||||
this.pending.set(index, call);
|
this.pending.set(index, call);
|
||||||
});
|
});
|
||||||
return asText(delta.content);
|
return asText(delta.content);
|
||||||
@@ -216,10 +262,24 @@ class ChatTurn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
calls(): AiToolCall[] {
|
calls(): AiToolCall[] {
|
||||||
return [...this.pending.entries()]
|
return this.named().map(([index, call]) => ({
|
||||||
.sort(([a], [b]) => a - b)
|
id: callId(index, call.id),
|
||||||
.filter(([, call]) => call.name)
|
name: call.name,
|
||||||
.map(([index, call]) => ({ id: call.id || `call_${index}`, name: call.name, args: parseToolArgs(call.args) }));
|
args: parseToolArgs(call.args),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The thought signature of each signed call, by the id the call goes back with. */
|
||||||
|
signatures(): Record<string, string> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
this.named()
|
||||||
|
.filter(([, call]) => call.signature)
|
||||||
|
.map(([index, call]) => [callId(index, call.id), call.signature]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private named(): [number, PendingCall][] {
|
||||||
|
return [...this.pending.entries()].sort(([a], [b]) => a - b).filter(([, call]) => call.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
finishReason(): AiFinishReason {
|
finishReason(): AiFinishReason {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { haversineKm } from '../common/geo';
|
|||||||
import { localParts, resolveTimeZone } from '../common/timezoneService';
|
import { localParts, resolveTimeZone } from '../common/timezoneService';
|
||||||
import type { EndpointInput } from '../reservations/reservations.service';
|
import type { EndpointInput } from '../reservations/reservations.service';
|
||||||
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transit.helpers';
|
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transit.helpers';
|
||||||
|
import type { TransitProvider } from '@trek/shared';
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -205,10 +206,12 @@ function timezoneAt(lat: number, lng: number): string {
|
|||||||
return timezone;
|
return timezone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `provider` is the backend the itinerary came from, recorded with the journey. */
|
||||||
export function buildTransitReservationParts(
|
export function buildTransitReservationParts(
|
||||||
from: TransitPlaceInput,
|
from: TransitPlaceInput,
|
||||||
to: TransitPlaceInput,
|
to: TransitPlaceInput,
|
||||||
itinerary: TransitItinerary,
|
itinerary: TransitItinerary,
|
||||||
|
provider: TransitProvider,
|
||||||
) {
|
) {
|
||||||
// The same stop appears up to three times below (transfer endpoint + the
|
// The same stop appears up to three times below (transfer endpoint + the
|
||||||
// adjacent legs' metadata) — memoise the tz lookup per coordinate pair.
|
// adjacent legs' metadata) — memoise the tz lookup per coordinate pair.
|
||||||
@@ -276,7 +279,7 @@ export function buildTransitReservationParts(
|
|||||||
const stats = deriveTransitStats(itinerary.startTime, itinerary.endTime, itinerary.legs, itinerary.transfers);
|
const stats = deriveTransitStats(itinerary.startTime, itinerary.endTime, itinerary.legs, itinerary.transfers);
|
||||||
const metadata = {
|
const metadata = {
|
||||||
transit: {
|
transit: {
|
||||||
provider: 'transitous',
|
provider,
|
||||||
duration: stats.duration,
|
duration: stats.duration,
|
||||||
transfers: stats.transfers,
|
transfers: stats.transfers,
|
||||||
walk_seconds: stats.walkSeconds,
|
walk_seconds: stats.walkSeconds,
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export class TransitMcp {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'search_transit_routes',
|
name: 'search_transit_routes',
|
||||||
description:
|
description:
|
||||||
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
|
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey, and `provider`, the backend that answered. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
from: transitPlaceInput,
|
from: transitPlaceInput,
|
||||||
to: transitPlaceInput,
|
to: transitPlaceInput,
|
||||||
@@ -169,7 +169,7 @@ export class TransitMcp {
|
|||||||
// A rejected itinerary is provider data we could not vouch for, but dropping it
|
// A rejected itinerary is provider data we could not vouch for, but dropping it
|
||||||
// silently is indistinguishable from "no routes exist" — report the count so the
|
// silently is indistinguishable from "no routes exist" — report the count so the
|
||||||
// caller knows the difference.
|
// caller knows the difference.
|
||||||
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length });
|
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length, provider: result.provider });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return errorResult(err, 'Transit route search failed.');
|
return errorResult(err, 'Transit route search failed.');
|
||||||
}
|
}
|
||||||
@@ -228,9 +228,11 @@ export class TransitMcp {
|
|||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// The itinerary came from search_transit_routes, which the active backend answered.
|
||||||
|
const provider = await this.transit.activeProvider(ctx.userId);
|
||||||
let reservationParts: ReturnType<typeof buildTransitReservationParts>;
|
let reservationParts: ReturnType<typeof buildTransitReservationParts>;
|
||||||
try {
|
try {
|
||||||
reservationParts = buildTransitReservationParts(from, to, cleaned);
|
reservationParts = buildTransitReservationParts(from, to, cleaned, provider);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return errorResult(err, 'Unable to resolve the transit journey timezones.');
|
return errorResult(err, 'Unable to resolve the transit journey timezones.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,11 @@ export class TransitService {
|
|||||||
@Inject(transitConfig.KEY) private readonly transitEnv: ConfigType<typeof transitConfig>,
|
@Inject(transitConfig.KEY) private readonly transitEnv: ConfigType<typeof transitConfig>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** The backend that answers this user's searches: Google where it is set up and keyed, Transitous otherwise. */
|
||||||
|
async activeProvider(userId = 0): Promise<TransitProvider> {
|
||||||
|
return (await this.google.isActive(userId)) ? 'google' : 'transitous';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Station/place search for the from/to pickers. `near` biases results.
|
* Station/place search for the from/to pickers. `near` biases results.
|
||||||
*
|
*
|
||||||
@@ -175,7 +180,7 @@ export class TransitService {
|
|||||||
const text = (query || '').trim();
|
const text = (query || '').trim();
|
||||||
// Answered before either backend is consulted, so it reports the one that
|
// Answered before either backend is consulted, so it reports the one that
|
||||||
// WOULD have been asked rather than claiming nobody was.
|
// WOULD have been asked rather than claiming nobody was.
|
||||||
const provider: TransitProvider = (await this.google.isActive(userId)) ? 'google' : 'transitous';
|
const provider = await this.activeProvider(userId);
|
||||||
if (text.length < 2) return { results: [], provider };
|
if (text.length < 2) return { results: [], provider };
|
||||||
if (text.length > 200) {
|
if (text.length > 200) {
|
||||||
const e = new Error('Query too long') as Error & { status: number };
|
const e = new Error('Query too long') as Error & { status: number };
|
||||||
@@ -259,7 +264,7 @@ export class TransitService {
|
|||||||
|
|
||||||
// After validation on purpose: whichever backend answers, the caller is held
|
// After validation on purpose: whichever backend answers, the caller is held
|
||||||
// to the same coordinate/mode/transfer contract and gets the same 400s.
|
// to the same coordinate/mode/transfer contract and gets the same 400s.
|
||||||
if (await this.google.isActive(userId)) {
|
if ((await this.activeProvider(userId)) === 'google') {
|
||||||
return { ...(await this.google.plan(q, language, userId)), provider: 'google' };
|
return { ...(await this.google.plan(q, language, userId)), provider: 'google' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
|||||||
titleKey: 'system_notice.release_notes.headline',
|
titleKey: 'system_notice.release_notes.headline',
|
||||||
bodyKey: 'system_notice.release_notes.intro',
|
bodyKey: 'system_notice.release_notes.intro',
|
||||||
release: {
|
release: {
|
||||||
version: '5.0.0',
|
version: '5.0.1',
|
||||||
eyebrowKey: 'system_notice.release_notes.eyebrow',
|
eyebrowKey: 'system_notice.release_notes.eyebrow',
|
||||||
headlineKey: 'system_notice.release_notes.headline',
|
headlineKey: 'system_notice.release_notes.headline',
|
||||||
introKey: 'system_notice.release_notes.intro',
|
introKey: 'system_notice.release_notes.intro',
|
||||||
@@ -95,7 +95,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
|||||||
footnoteKey: 'system_notice.release_notes.footnote',
|
footnoteKey: 'system_notice.release_notes.footnote',
|
||||||
notes: {
|
notes: {
|
||||||
labelKey: 'system_notice.release_notes.notes_label',
|
labelKey: 'system_notice.release_notes.notes_label',
|
||||||
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.0',
|
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.1',
|
||||||
},
|
},
|
||||||
note: {
|
note: {
|
||||||
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
|
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ describe('MCP transit tools', () => {
|
|||||||
expect(routes.itineraries[0].legs[0].from.name).toBe('Namba');
|
expect(routes.itineraries[0].legs[0].from.name).toBe('Namba');
|
||||||
expect(routes.itineraries[0].legs[1].to.name).toBe('Umeda');
|
expect(routes.itineraries[0].legs[1].to.name).toBe('Umeda');
|
||||||
expect(routes.itineraries).toHaveLength(1);
|
expect(routes.itineraries).toHaveLength(1);
|
||||||
|
expect(routes.provider).toBe('transitous');
|
||||||
expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' }), undefined, user.id);
|
expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' }), undefined, user.id);
|
||||||
|
|
||||||
const invalidNear = await harness.client.callTool({
|
const invalidNear = await harness.client.callTool({
|
||||||
@@ -244,6 +245,27 @@ describe('MCP transit tools', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records Google as the provider of a journey planned while Google answers searches (#2850)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
|
||||||
|
const day = await dayOf(trip.id, '2026-12-03');
|
||||||
|
const active = vi.spyOn(TransitService.prototype, 'activeProvider').mockResolvedValue('google');
|
||||||
|
try {
|
||||||
|
await withHarness(user.id, ['reservations:write'], async (harness) => {
|
||||||
|
const result = parseToolResult(
|
||||||
|
await harness.client.callTool({
|
||||||
|
name: 'create_transit_journey',
|
||||||
|
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary },
|
||||||
|
}),
|
||||||
|
) as { reservation: { metadata: string } };
|
||||||
|
expect(JSON.parse(result.reservation.metadata).transit.provider).toBe('google');
|
||||||
|
});
|
||||||
|
expect(active).toHaveBeenCalledWith(user.id);
|
||||||
|
} finally {
|
||||||
|
active.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects dateless, mismatched, and out-of-range journey dates', async () => {
|
it('rejects dateless, mismatched, and out-of-range journey dates', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const datelessTrip = createTrip(testDb, user.id);
|
const datelessTrip = createTrip(testDb, user.id);
|
||||||
|
|||||||
@@ -529,6 +529,125 @@ describe('OpenAiChatClient', () => {
|
|||||||
await failure(client(streamResponse([{ choices: [{ delta: { content: 'half' } }] }])).stream(REQUEST, signal())),
|
await failure(client(streamResponse([{ choices: [{ delta: { content: 'half' } }] }])).stream(REQUEST, signal())),
|
||||||
).toMatchObject({ providerCode: 'stream_cut' });
|
).toMatchObject({ providerCode: 'stream_cut' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Gemini 3 signs the first call of a step on its first piece, the way its Chat Completions endpoint streams it.
|
||||||
|
const SIGNED = [
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
delta: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
id: 'function-call-1',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'create_place', arguments: '{"name":"Nijo"}' },
|
||||||
|
extra_content: { google: { thought_signature: 'sig-A' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
delta: {
|
||||||
|
tool_calls: [{ index: 1, function: { name: 'list_places', arguments: '{}' } }],
|
||||||
|
},
|
||||||
|
finish_reason: 'tool_calls',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'[DONE]',
|
||||||
|
];
|
||||||
|
|
||||||
|
it('OACH-006: a signed call comes back as the provider state, keyed by the id it goes on with', async () => {
|
||||||
|
const events = await run(
|
||||||
|
new OpenAiChatClient(
|
||||||
|
'gemini',
|
||||||
|
'gemini-3-flash',
|
||||||
|
'https://g',
|
||||||
|
'k',
|
||||||
|
fetchAnswering(streamResponse(SIGNED)).fetcher,
|
||||||
|
).stream(REQUEST, signal()),
|
||||||
|
);
|
||||||
|
expect(events.filter((event) => event.type === 'tool-call')).toEqual([
|
||||||
|
{ type: 'tool-call', call: { id: 'function-call-1', name: 'create_place', args: { name: 'Nijo' } } },
|
||||||
|
{ type: 'tool-call', call: { id: 'call_1', name: 'list_places', args: {} } },
|
||||||
|
]);
|
||||||
|
expect(events.slice(-2)).toEqual([
|
||||||
|
{
|
||||||
|
type: 'provider-state',
|
||||||
|
state: { adapter: 'openai-chat', model: 'gemini-3-flash', signatures: { 'function-call-1': 'sig-A' } },
|
||||||
|
},
|
||||||
|
{ type: 'finish', reason: 'tool-calls' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// An unsigned turn keeps no state: nothing is stored for a provider that signs nothing.
|
||||||
|
const plain = await run(
|
||||||
|
new OpenAiChatClient('openai', 'm', 'https://x', 'k', fetchAnswering(streamResponse(STREAM)).fetcher).stream(
|
||||||
|
REQUEST,
|
||||||
|
signal(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(plain.some((event) => event.type === 'provider-state')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OACH-007: the signature goes back with its call to the model that signed it, and to no other', async () => {
|
||||||
|
const signed: AiChatRequest = {
|
||||||
|
...REQUEST,
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', text: 'Add Nijo' },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
text: '',
|
||||||
|
toolCalls: [
|
||||||
|
{ id: 'function-call-1', name: 'create_place', args: { name: 'Nijo' } },
|
||||||
|
{ id: 'call_1', name: 'list_places', args: {} },
|
||||||
|
],
|
||||||
|
providerState: {
|
||||||
|
adapter: 'openai-chat',
|
||||||
|
model: 'gemini-3-flash',
|
||||||
|
signatures: { 'function-call-1': 'sig-A' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ role: 'tool', toolCallId: 'function-call-1', name: 'create_place', output: '{"id":7}' },
|
||||||
|
{ role: 'tool', toolCallId: 'call_1', name: 'list_places', output: '[]' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const http = fetchAnswering(streamResponse(['[DONE]']));
|
||||||
|
await run(
|
||||||
|
new OpenAiChatClient('gemini', 'gemini-3-flash', 'https://g', 'k', http.fetcher).stream(signed, signal()),
|
||||||
|
);
|
||||||
|
expect(dig(http.body(), 'messages', 2, 'tool_calls')).toEqual([
|
||||||
|
{
|
||||||
|
id: 'function-call-1',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'create_place', arguments: '{"name":"Nijo"}' },
|
||||||
|
extra_content: { google: { thought_signature: 'sig-A' } },
|
||||||
|
},
|
||||||
|
{ id: 'call_1', type: 'function', function: { name: 'list_places', arguments: '{}' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A thread that moved on to another model sends the calls plain: no other model reads the signature.
|
||||||
|
for (const messages of [toChatMessages(signed, 'gpt-5-mini'), toChatMessages(signed)]) {
|
||||||
|
expect(JSON.stringify(messages)).not.toContain('extra_content');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OACH-008: an own server on Gemini’s endpoint gets Gemini’s narrower schemas, any other the full ones', async () => {
|
||||||
|
const parameters = async (baseUrl: string) => {
|
||||||
|
const http = fetchAnswering(streamResponse(['[DONE]']));
|
||||||
|
await run(new OpenAiChatClient('custom', 'm', baseUrl, 'k', http.fetcher).stream(REQUEST, signal()));
|
||||||
|
return dig(http.body(), 'tools', 0, 'function', 'parameters');
|
||||||
|
};
|
||||||
|
expect(await parameters('https://generativelanguage.googleapis.com/v1beta/openai')).toEqual(
|
||||||
|
toProviderSchema(TOOL.parameters, 'gemini'),
|
||||||
|
);
|
||||||
|
expect(await parameters('http://ollama:11434/v1')).toEqual(toProviderSchema(TOOL.parameters, 'custom'));
|
||||||
|
expect(await parameters('not a url')).toEqual(toProviderSchema(TOOL.parameters, 'custom'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('OpenAiResponsesClient', () => {
|
describe('OpenAiResponsesClient', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { AssistantMessageRow } from '../../../../src/db/repositories/Assist
|
|||||||
import type { AssistantToolCallRow } from '../../../../src/db/repositories/AssistantToolCalls.repository';
|
import type { AssistantToolCallRow } from '../../../../src/db/repositories/AssistantToolCalls.repository';
|
||||||
import type { AiChatMessage } from '../../../../src/nest/ai-connections/ai-chat-client.interface';
|
import type { AiChatMessage } from '../../../../src/nest/ai-connections/ai-chat-client.interface';
|
||||||
import { toAnthropicMessages } from '../../../../src/nest/ai-connections/providers/anthropic-messages.client';
|
import { toAnthropicMessages } from '../../../../src/nest/ai-connections/providers/anthropic-messages.client';
|
||||||
|
import { toChatMessages } from '../../../../src/nest/ai-connections/providers/openai-chat.client';
|
||||||
import { toResponsesInput } from '../../../../src/nest/ai-connections/providers/openai-responses.client';
|
import { toResponsesInput } from '../../../../src/nest/ai-connections/providers/openai-responses.client';
|
||||||
import {
|
import {
|
||||||
messageIdOf,
|
messageIdOf,
|
||||||
@@ -573,6 +574,23 @@ describe('toChatHistory: calls the model made of the server tools (#2845)', () =
|
|||||||
expect(messages.flatMap((message) => message.content).some((block) => block.text === '')).toBe(false);
|
expect(messages.flatMap((message) => message.content).some((block) => block.text === '')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ASTHIS-018: Gemini gets every stored call back with its thought signature, an applied change included', () => {
|
||||||
|
const state = (id: string, signature: string) => ({
|
||||||
|
adapter: 'openai-chat',
|
||||||
|
model: 'gemini-3-flash',
|
||||||
|
signatures: { [id]: signature },
|
||||||
|
});
|
||||||
|
type SentCall = { id: string; extra_content?: { google: { thought_signature: string } } };
|
||||||
|
const history = appliedAfterANote(state('call_note', 'sig-note'), state('call_move', 'sig-move'));
|
||||||
|
const sent = toChatMessages({ system: '', messages: history, tools: [] }, 'gemini-3-flash')
|
||||||
|
.flatMap((message) => (message.tool_calls ?? []) as SentCall[])
|
||||||
|
.map((call) => [call.id, call.extra_content?.google.thought_signature]);
|
||||||
|
expect(sent).toEqual([
|
||||||
|
['call_note', 'sig-note'],
|
||||||
|
['call_move', 'sig-move'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('ASTHIS-017: a step that only kept a note is the model call it was, answered with what was kept', () => {
|
it('ASTHIS-017: a step that only kept a note is the model call it was, answered with what was kept', () => {
|
||||||
const history = toChatHistory(
|
const history = toChatHistory(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ describe('buildTransitReservationParts', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
|
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
|
||||||
|
|
||||||
expect(endpoints).toEqual([
|
expect(endpoints).toEqual([
|
||||||
{
|
{
|
||||||
@@ -407,7 +407,7 @@ describe('buildTransitReservationParts', () => {
|
|||||||
const kyoto = { name: 'Kyoto', lat: 35.0116, lng: 135.7681 };
|
const kyoto = { name: 'Kyoto', lat: 35.0116, lng: 135.7681 };
|
||||||
const itinerary = twoLegs();
|
const itinerary = twoLegs();
|
||||||
itinerary.legs = [{ ...itinerary.legs[0], to: stop('Umeda', 34.702, 135.496, null) }, itinerary.legs[1]];
|
itinerary.legs = [{ ...itinerary.legs[0], to: stop('Umeda', 34.702, 135.496, null) }, itinerary.legs[1]];
|
||||||
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
|
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
|
||||||
expect(endpoints[1]).toEqual({
|
expect(endpoints[1]).toEqual({
|
||||||
role: 'stop',
|
role: 'stop',
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
@@ -423,14 +423,18 @@ describe('buildTransitReservationParts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('TRANSIT-ITIN-020: an unresolvable endpoint timezone throws with the coordinates in the message', () => {
|
it('TRANSIT-ITIN-020: an unresolvable endpoint timezone throws with the coordinates in the message', () => {
|
||||||
expect(() => buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg())).toThrow(
|
expect(() =>
|
||||||
'Unable to resolve timezone for NaN,135.501.',
|
buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg(), 'transitous'),
|
||||||
);
|
).toThrow('Unable to resolve timezone for NaN,135.501.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TRANSIT-ITIN-021: an unconvertible itinerary time throws with the ISO string and timezone', () => {
|
it('TRANSIT-ITIN-021: an unconvertible itinerary time throws with the ISO string and timezone', () => {
|
||||||
expect(() => buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }))).toThrow(
|
expect(() =>
|
||||||
'Unable to convert not-a-date to local time in Asia/Tokyo.',
|
buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }), 'transitous'),
|
||||||
);
|
).toThrow('Unable to convert not-a-date to local time in Asia/Tokyo.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRANSIT-ITIN-022: the journey records the backend its itinerary came from (#2850)', () => {
|
||||||
|
expect(buildTransitReservationParts(namba, umeda, singleLeg(), 'google').metadata.transit.provider).toBe('google');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -133,8 +133,8 @@ Transit search is powered by Transitous and uses the existing `geo:read` and `re
|
|||||||
| Tool | Scope required | Description |
|
| Tool | Scope required | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `search_transit_stops` | `geo:read` | Search real public-transit stops and stations, optionally biased around coordinates. |
|
| `search_transit_stops` | `geo:read` | Search real public-transit stops and stations, optionally biased around coordinates. |
|
||||||
| `search_transit_routes` | `geo:read` | Search scheduled routes between two coordinates with time, mode, and transfer filters. Also returns `dropped`, the number of provider itineraries that failed validation and are absent from the results. |
|
| `search_transit_routes` | `geo:read` | Search scheduled routes between two coordinates with time, mode, and transfer filters. Also returns `provider` (`transitous` or `google`, the backend that answered) and `dropped`, the number of provider itineraries that failed validation and are absent from the results. |
|
||||||
| `create_transit_journey` | `reservations:write` | Save a selected route as a first-class automated transit journey on a trip day. |
|
| `create_transit_journey` | `reservations:write` | Save a selected route as a first-class automated transit journey on a trip day, recording the backend that plans routes for the caller as its provider. |
|
||||||
|
|
||||||
### Reservations
|
### Reservations
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user