fix(client): the audit round over the 4.3.1 release

Ride minutes and late marks follow an edited timetable instead of the routing
that fetched them; a landing day's join band reads as the ride it is rather
than a nine hour drive of no length; the refuel offer is there on the road
out of a terminal or a pick-up desk; the phone's stage map draws the ride;
the share page numbers its stops the way the planner does; the PDF lists a
day the way the day plan lists it, from both shells; a poster-less clip draws
its play badge everywhere, not only in the editor; the hero title has room
for its descenders and the list card keeps its badge clear; the release
notice keeps its close control in reach when it stacks; a link chip's actions
are thumb-sized and its delete asks first; the Google retry line and the
Google-only switch appear only where Google answers.
This commit is contained in:
Maurice
2026-09-22 12:10:21 +02:00
parent cb85ba915c
commit 85b5756e89
78 changed files with 2476 additions and 486 deletions
+5 -2
View File
@@ -284,7 +284,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -301,7 +301,7 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
@@ -359,6 +359,9 @@ export default function App() {
return
}
}
// Last, so the version block above keeps its lines: the taint analyser
// re-raises its browser-storage finding for every line that moves under it.
if (config?.places_provider) setPlacesProvider(config.places_provider)
}).catch(() => {})
}, [])
+4 -4
View File
@@ -1216,11 +1216,11 @@ export const mapsApi = {
* caller of this one function gets them without knowing they exist. Appended rather
* than interleaved: the core list is ordered by relevance and has earned that order,
* and a plugin's row carries its own `source` for a caller that wants to mark it.
*/
/**
*
* `provider: 'google'` sends this one search to Google alone, the "search Google
* instead" link under a list the index answered with the wrong place. The
* server ignores it on an instance without a Google key.
* instead" link under a list the index answered with the wrong place. The server
* ignores it unless Google holds the keyed slot: without a Google key, or with
* Amap or OpenStreetMap picked as the provider, the index answers as usual.
*/
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') =>
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
@@ -1,4 +1,4 @@
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008
// FE-COMP-LINKS-001 to FE-COMP-LINKS-013
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -10,7 +10,9 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(),
}));
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -160,6 +162,65 @@ describe('CollabLinks', () => {
expect(screen.getByText('ferries.example')).toBeInTheDocument();
});
it('FE-COMP-LINKS-011: deleting asks first, and the DELETE goes out only once confirmed', async () => {
// The link goes for every member, and on the phone the button is a thumb's
// width from pin: the notes panel guards the same way.
const user = userEvent.setup();
let deleted = false;
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
http.delete('/api/trips/1/collab/links/1', () => { deleted = true; return HttpResponse.json({ success: true }); }),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /delete link|collab\.links\.delete/i }));
expect(await screen.findByText(/delete link\?|collab\.links\.confirmDeleteTitle/i)).toBeInTheDocument();
expect(deleted).toBe(false);
expect(screen.getByText('Ferry timetable')).toBeInTheDocument();
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
await waitFor(() => expect(deleted).toBe(true));
await waitFor(() => expect(screen.queryByText('Ferry timetable')).not.toBeInTheDocument());
});
it('FE-COMP-LINKS-012: a corrected address gets a fresh favicon attempt', async () => {
// The icon remembers a favicon that failed, and an edit keeps the chip's id,
// so the corrected address kept the generic glyph until the panel remounted.
const user = userEvent.setup();
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink({ url: 'https://old.example/page' })] })),
http.put('/api/trips/1/collab/links/1', () => HttpResponse.json({ link: buildLink({ url: 'https://new.example/page' }) })),
);
render(<CollabLinks tripId={1} />);
const chip = await screen.findByRole('link', { name: /ferry timetable/i });
fireEvent.error(chip.querySelector('img') as HTMLImageElement);
await waitFor(() => expect(chip.querySelector('img')).toBeNull());
await user.click(screen.getByRole('button', { name: /edit link|collab\.links\.edit/i }));
const url = await screen.findByLabelText(/https|collab\.links\.urlPlaceholder/i);
await user.clear(url);
await user.type(url, 'https://new.example/page');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
await waitFor(() => expect(screen.getByRole('link', { name: /ferry timetable/i }).querySelector('img'))
.toHaveAttribute('src', 'https://new.example/favicon.ico'));
});
it('FE-COMP-LINKS-013: on touch the chip actions are finger-sized and apart', () => {
// jsdom lays nothing out, so the rule is the assertion. The phone Collab tab
// mounts this chip; 22px buttons a pixel apart put delete one slip from pin.
const css = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8');
const at = css.indexOf('@media (hover: none) {\n .collab-link-chip__actions');
expect(at, 'touch rules for the chip actions missing from index.css').toBeGreaterThan(-1);
const touch = css.slice(at, css.indexOf('\n}', at));
expect(touch).toMatch(/\.collab-link-chip__actions \{[^}]*gap:\s*4px/);
const action = /\.collab-link-chip__action \{([^}]*)\}/.exec(touch)?.[1] ?? '';
for (const side of ['width', 'height']) {
expect(Number(new RegExp(`${side}:\\s*(\\d+)px`).exec(action)?.[1]), side).toBeGreaterThanOrEqual(24);
}
// The height comes back through the margin, so the chip itself stays 36px.
expect(action).toMatch(/margin:\s*-4px 0/);
});
it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => {
// collab_edit reserved for the owner, on somebody else's trip.
seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } });
+16 -2
View File
@@ -6,6 +6,7 @@ import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import ConfirmDialog from '../shared/ConfirmDialog'
import EmptyState from '../shared/EmptyState'
import { useToast } from '../shared/Toast'
@@ -111,6 +112,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
const [links, setLinks] = useState<CollabLink[]>([])
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CollabLink | null>(null)
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null)
// Both are new objects on every render, so depending on them would reload the
// list after each keystroke and drop a link that was just added back out of it.
@@ -230,7 +232,9 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
{links.map(link => (
<div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}>
<a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer" title={t('collab.links.open')}>
<LinkIcon url={link.url} />
{/* Keyed on the address: the icon remembers a favicon that failed, and
an edit keeps the chip's id, so a corrected address got the glyph. */}
<LinkIcon key={link.url} url={link.url} />
<span className="collab-link-chip__text">
<span className="collab-link-chip__title">{link.title}</span>
<span className="collab-link-chip__host">{hostOf(link.url)}</span>
@@ -244,7 +248,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
<button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} title={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}>
<Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" className="collab-link-chip__action" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<button type="button" className="collab-link-chip__action" onClick={() => setPendingDeleteId(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<Trash2 size={13} aria-hidden="true" />
</button>
</span>
@@ -258,6 +262,16 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
{/* Create and edit modals */}
{showForm && <LinkModal onClose={() => setShowForm(false)} onSave={add} />}
{editing && <LinkModal key={editing.id} link={editing} onClose={() => setEditing(null)} onSave={data => edit(editing, data)} />}
{/* The delete is shared: the link goes for every member, and on the phone the
button is a thumb's width from pin. Same guard as the notes panel. */}
<ConfirmDialog
isOpen={pendingDeleteId !== null}
onClose={() => setPendingDeleteId(null)}
onConfirm={() => (pendingDeleteId !== null ? remove(pendingDeleteId) : undefined)}
title={t('collab.links.confirmDeleteTitle')}
message={t('collab.links.confirmDeleteBody')}
/>
</div>
)
}
@@ -1,4 +1,4 @@
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-040
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-059
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -1045,4 +1045,30 @@ describe('EntryEditor', () => {
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-058: the gallery picker gives a clip without a poster the same play badge (#2341)', async () => {
const user = userEvent.setup()
const clip = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = mountEditor(buildEntry(), { galleryPhotos: [clip] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
expect(container.querySelector('img[src="/api/photos/200/thumbnail"]')).not.toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-059: a clip whose poster fails to load is not retried as the clip itself', async () => {
// The /original of a video is the video file, which an <img> cannot draw.
const user = userEvent.setup()
const strip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/a.jpg' }
const picker = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: 'journey/b.jpg' }
const { container } = mountEditor(buildEntry({ id: 10, photos: [strip] }), { galleryPhotos: [picker] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
for (const id of [100, 200]) {
const img = container.querySelector(`img[src="/api/photos/${id}/thumbnail"]`) as HTMLImageElement
fireEvent.error(img)
expect(img.getAttribute('src')).toBe(`/api/photos/${id}/thumbnail`)
}
})
})
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { useEffect, useMemo, useState, useRef, type SyntheticEvent } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
@@ -21,6 +21,27 @@ import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourn
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
// A photo whose thumbnail is missing can still be drawn from its original. A
// clip's original is the video file, which no <img> can show, so a clip gets
// no fallback (#2341).
function thumbnailFallback(p: { photo_id: number; media_type?: string | null }) {
if (p.media_type === 'video') return undefined
return (e: SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget
if (!img.src.includes('/original')) img.src = photoUrl(p, 'original')
}
}
// No poster to show and falling back to /original would hand an <img> the clip
// itself, so this tile stays a play badge.
function ClipTile() {
return (
<div className="absolute inset-0 bg-black flex items-center justify-center text-white">
<Play size={18} className="ml-0.5" fill="currentColor" />
</div>
)
}
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
entry: JourneyEntry
journeyId: number
@@ -434,7 +455,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
className="relative block w-full rounded-xl overflow-hidden border-0 p-0 bg-transparent cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
style={{ paddingTop: '100%' }}
>
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
{posterlessVideo(gp) ? (
<ClipTile />
) : (
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={thumbnailFallback(gp)} />
)}
</button>
))}
{availableGalleryPhotos.length === 0 && (
@@ -528,13 +553,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{photos.map((p, idx) => (
<div key={p.id} className={`w-20 h-20 rounded-xl overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
{posterlessVideo(p) ? (
// No poster to show and falling back to /original would hand
// an <img> the clip itself, so this tile stays a play badge.
<div className="w-full h-full bg-black flex items-center justify-center text-white">
<Play size={18} className="ml-0.5" fill="currentColor" />
</div>
<ClipTile />
) : (
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={thumbnailFallback(p)} />
)}
{idx === 0 && photos.length > 1 && (
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
@@ -1,4 +1,4 @@
// FE-JRN-COVER-001 to FE-JRN-COVER-008
// FE-JRN-COVER-001 to FE-JRN-COVER-010
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
@@ -112,4 +112,23 @@ describe('JourneyEntryCover', () => {
expect(onClick).toHaveBeenCalledOnce()
})
it('FE-JRN-COVER-009: a clip without a poster falls back to the ground, marked as a clip (#2341)', () => {
// Its thumbnail route 404s on purpose, so an <img> would have been a broken face
// across the whole card.
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: null }] })
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img[src*="/thumbnail"]')).toBeNull()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-map-pin')).toBeNull()
})
it('FE-JRN-COVER-010: a clip with its poster fills the card like any photo', () => {
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }] })
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
expect(container.querySelector('svg.lucide-play')).toBeNull()
})
})
@@ -1,6 +1,7 @@
import { MapPin, Plus } from 'lucide-react'
import { MapPin, Play, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import CountryFlag from '../shared/CountryFlag'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard'
import type { JourneyEntry } from '../../store/journeyStore'
@@ -25,6 +26,13 @@ import type { JourneyEntry } from '../../store/journeyStore'
* which needs no tokens at all.
*/
/** A card's photo, plus what says whether a clip has a poster to draw (#2341). */
export interface CoverPhoto extends CardPhoto {
media_type?: string | null
provider?: string | null
thumbnail_path?: string | null
}
/** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */
export interface CoverEntry {
id: number
@@ -39,7 +47,7 @@ export interface CoverEntry {
entry_time?: string | null
mood?: string | null
weather?: string | null
photos?: CardPhoto[]
photos?: CoverPhoto[]
}
interface Props {
@@ -69,8 +77,12 @@ export default function JourneyEntryCover({
const { t, locale } = useTranslation()
const isSuggestion = entry.type === 'skeleton'
const photoId = cardPhotoId(entry.photos?.[0])
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
const first = entry.photos?.[0]
const photoId = cardPhotoId(first)
// A clip without a poster has no picture to fill the card with; its thumbnail
// route 404s and an <img> pointed at it would draw the broken glyph (#2341).
const posterless = first != null && posterlessVideo(first)
const src = photoId == null || posterless ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
const title = cardTitle(entry, t)
const place = cardPlace(entry)
@@ -112,7 +124,9 @@ export default function JourneyEntryCover({
<img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full rounded-[18px] object-cover" />
) : (
<span className="absolute inset-0 flex items-center justify-center">
<MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />
{posterless
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={{ color: dayColor }} className="ml-0.5 opacity-60" />
: <MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />}
</span>
)}
@@ -1,4 +1,4 @@
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -234,4 +234,25 @@ describe('MobileEntryView', () => {
expect(screen.queryByText('Amazing')).not.toBeInTheDocument()
expect(screen.queryByText('Sunny')).not.toBeInTheDocument()
})
it('FE-COMP-JENTRYVIEW-019: a clip is drawn from its poster, in the hero as in the strip (#2341)', () => {
// Its original is the video file, which an <img> cannot show, so the hero used
// to be a broken image for every clip, poster or not.
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
const images = container.querySelectorAll('img')
expect(images[0]).toHaveAttribute('src', '/api/photos/4/thumbnail')
expect(images[1]).toHaveAttribute('src', '/api/photos/4/thumbnail')
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
})
it('FE-COMP-JENTRYVIEW-020: a clip without a poster keeps the play badge and asks for no image at all', () => {
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
const images = container.querySelectorAll('img')
// The second photo's strip thumbnail is the only image left.
expect(images).toHaveLength(1)
expect(images[0]).toHaveAttribute('src', '/api/photos/5/thumbnail')
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
})
})
@@ -7,6 +7,7 @@ import {
import JournalBody from './JournalBody'
import { useTranslation } from '../../i18n'
import { formatLocationName } from '../../utils/formatters'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
// Labels are translation keys, like the desktop tables in
@@ -105,12 +106,18 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`}
onClick={() => onPhotoClick(photos, 0)}
>
<img
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{/* A clip is drawn from its poster, its original being the video file.
Without a poster the black ground and the play badge are the hero (#2341). */}
{posterlessVideo(photos[0]) ? (
<span className="block aspect-video w-full max-h-[50vh]" />
) : (
<img
src={photoUrl(photos[0], photos[0].media_type === 'video' ? 'thumbnail' : 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
{photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -140,12 +147,14 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`}
onClick={() => onPhotoClick(photos, i)}
>
<img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{!posterlessVideo(p) && (
<img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
{p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -3,10 +3,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
const plyrCtor = vi.fn()
vi.mock('plyr', () => ({
default: class {
el: HTMLElement
original: Node
constructor(el: HTMLElement, options: Record<string, unknown>) {
plyrCtor(el, options)
this.el = el
this.original = el.cloneNode(true)
}
// What the real destroy() does to the document: the element as it was at
// construction goes back in, whatever React wrote to it since.
destroy() {
this.el.replaceWith(this.original)
}
destroy = vi.fn()
},
}))
vi.mock('plyr/dist/plyr.css', () => ({}))
@@ -31,7 +39,7 @@ describe('VideoPlayer', () => {
it('resets the element to an empty source on teardown rather than a clip on the CDN', () => {
// media-src refuses cdn.plyr.io too; an empty source aborts the stream just the
// same, and the element is unmounted in the same tick.
// same, and the element is out of the document by then.
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
const [, options] = plyrCtor.mock.calls[0]
@@ -45,4 +53,19 @@ describe('VideoPlayer', () => {
expect(el).toBe(container.querySelector('video'))
expect(container.querySelector('video')).toHaveAttribute('poster', '/api/photos/7/thumbnail')
})
it('stepping to another clip builds the player on a fresh element, so the clip on screen is the new one', () => {
// Rebuilding Plyr on the same <video> ran after React had already pointed it at
// the next clip, and destroy() then swapped the first clip back in. The file
// lightboxes do not key the player themselves, so this has to hold here.
const { container, rerender } = render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
rerender(<VideoPlayer src="/api/photos/8/original" autoPlay={false} />)
expect(plyrCtor).toHaveBeenCalledTimes(2)
const [first] = plyrCtor.mock.calls[0]
const [second] = plyrCtor.mock.calls[1]
expect(second).not.toBe(first)
expect(second).toBe(container.querySelector('video'))
expect(container.querySelector('video')).toHaveAttribute('src', '/api/photos/8/original')
})
})
+20 -14
View File
@@ -7,24 +7,30 @@ import 'plyr/dist/plyr.css'
// host and would fetch a data URL over XHR, which connect-src refuses as well.
import plyrSprite from 'plyr/dist/plyr.svg?no-inline'
interface Props {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}
/**
* Video player for gallery/lightbox playback (#823), built on Plyr over a native
* <video>. Local videos stream with HTTP Range (seeking works out of the box) and
* the source carries the correct video MIME from the server. The Plyr instance is
* created once per mounted source and destroyed on unmount, so navigating away in
* the lightbox stops playback.
*
* A new source is a new element: Plyr's destroy() puts a clone of the element as
* it was at construction back into the document, so rebuilding the player on a
* <video> React had already pointed at the next clip left the first clip on
* screen under the next one's name.
*/
export default function VideoPlayer({
src,
poster,
autoPlay = true,
style,
}: {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}): React.ReactElement {
export default function VideoPlayer(props: Props): React.ReactElement {
return <PlyrVideo key={props.src} {...props} />
}
function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactElement {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
@@ -48,8 +54,8 @@ export default function VideoPlayer({
iconUrl: plyrSprite,
// On teardown Plyr points the element at a blank clip to abort the stream,
// by default one on cdn.plyr.io, which media-src refuses. An empty source
// aborts the stream just the same, and the element is unmounted in the same
// tick, so the error it would otherwise report has nowhere to show.
// aborts the stream just the same, and the element is out of the document
// by then, so the error it would otherwise report has nowhere to show.
blankVideo: '',
})
@@ -60,7 +66,7 @@ export default function VideoPlayer({
/* already torn down */
}
}
}, [src, autoPlay])
}, [autoPlay])
return (
<div
+34 -1
View File
@@ -31,6 +31,12 @@ function getIframe(): HTMLIFrameElement | null {
return document.querySelector('#pdf-preview-overlay iframe')
}
/** The cover's "Planned" figure. The stat is read off its label, since days and places print the same markup. */
function plannedStat(html: string): number | null {
const match = /<div class="cover-stat-num">(\d+)<\/div>\s*<div class="cover-stat-lbl">pdf\.planned<\/div>/.exec(html)
return match ? Number(match[1]) : null
}
// ── Setup ─────────────────────────────────────────────────────────────────────
beforeEach(() => {
@@ -257,7 +263,7 @@ describe('downloadTripPDF', () => {
// The desktop toolbar hands the export the store's assignments, hidden stop and
// all, and that stop heads its check-in day. The day plan hides it and shows the
// booking as the accommodation block, and the print lists what the plan lists.
const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null } as any
const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null, price: '120' } as any
const hotelStop = { id: 201, day_id: 10, place_id: 101, order_index: 0, accommodation_id: 30, place: hotelPlace }
const flight = { ...transportReservation, title: 'Morning flight', reservation_time: '2025-06-01T08:00:00' }
await downloadTripPDF({
@@ -269,6 +275,33 @@ describe('downloadTripPDF', () => {
expect(html).not.toContain('Seewartenstr. 9')
expect(html.indexOf('Morning flight')).toBeLessThan(html.indexOf('Colosseum'))
expect(html.indexOf('Morning flight')).toBeGreaterThan(-1)
// The cover and the day header read the same list: the hidden stop is not a planned
// stop and its hotel's price is not part of the day, whichever shell asked.
expect(plannedStat(html)).toBe(1)
const money = html.replace(/[\u00a0\u202f]/g, ' ')
expect(money).toContain('15,00 €')
expect(money).not.toContain('135,00 €')
})
it('FE-COMP-TRIPPDF-013g: a service stop the plan keeps to the road trip view is not printed either, and comes back with the switch', async () => {
// "Show in Days too" off keeps petrol stations and rest areas out of the day lists.
// The print follows the same switch, so the desktop, which hands over the store's
// assignments, and the phone print the same day.
const pump = { id: 102, name: 'Aral Autohof', address: 'Autobahnkreuz 1', stop_type: 'fuel', place_time: null } as any
const pumpStop = { id: 202, day_id: 10, place_id: 102, order_index: 1, place: pump }
const assignments = { '10': [assignmentForDay, pumpStop] } as any
await downloadTripPDF({ ...richArgs, assignments, showServiceStops: false })
const hidden = getIframe()!.srcdoc
expect(hidden).not.toContain('Aral Autohof')
expect(hidden).toContain('Colosseum')
expect(plannedStat(hidden)).toBe(1)
// On, and by default, the stop prints where the plan lists it.
getOverlay()?.remove()
await downloadTripPDF({ ...richArgs, assignments })
const shown = getIframe()!.srcdoc
expect(shown).toContain('Aral Autohof')
expect(plannedStat(shown)).toBe(2)
})
it('FE-COMP-TRIPPDF-013c: a flight that lands the same day shows both times (#1310)', async () => {
+31 -10
View File
@@ -16,6 +16,7 @@ import { renderTripMapImage } from './tripMapImage'
import { formatDistance } from '../../utils/units'
import { fetchExchangeRates } from '../../hooks/useExchangeRates'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
import { isServiceStopType } from '../Roadtrip/roadtripModel'
/**
* Every day starts a new page by default. On a trip of short days that prints
@@ -199,11 +200,34 @@ interface downloadTripPDFProps {
timeFormat?: string
/** 'metric' | 'imperial'. Same reasoning as `timeFormat` — read as a prop, not a hook. */
distanceUnit?: string
/**
* The road trip setting "Show in Days too" (`roadtrip_service_stops_in_days`), read as a
* prop for the same reason. Off, the day plan keeps petrol stations and rest areas to
* the road trip view, and so does the print.
*/
showServiceStops?: boolean
}
// `assignments` is normalised here once — every read below (and fetchPlacePhotos)
// relies on it being an object.
export async function downloadTripPDF({ trip, days, places, assignments = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit }: downloadTripPDFProps) {
/**
* The stops the day plan lists, out of everything the store holds for the trip.
*
* The stop a booked night wrote onto its check-in day is out whatever the switch says:
* the day already shows that booking as its accommodation block, and the row would be
* the same hotel a second time. The service stops go with the setting. One predicate
* for the whole document, so the day lists, the route map, the cover's planned count
* and every cost total agree with the plan and with each other, whichever shell asked.
*/
function planAssignments(assignments: AssignmentsMap, showServiceStops: boolean): AssignmentsMap {
return Object.fromEntries(Object.entries(assignments).map(([dayId, list]) => [
dayId,
(list || []).filter(a => a.accommodation_id == null && (showServiceStops || !isServiceStopType(a.place?.stop_type))),
]))
}
// `assignments` is normalised here once, to the plan's own list; every read below
// (and fetchPlacePhotos) relies on it being an object.
export async function downloadTripPDF({ trip, days, places, assignments: stored = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true }: downloadTripPDFProps) {
const assignments = planAssignments(stored, showServiceStops)
const breaksPerDay = pageBreakPerDay()
const loc = _locale || undefined
const tr = _t || (k => k)
@@ -335,14 +359,11 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
}
// Build day HTML
const daysHtml = sorted.map((day, di) => {
// Without the stop a booked night wrote onto its check-in day: the day plan hides
// it (the day already carries the booking as its accommodation block) and the
// export lists what the plan lists. The desktop toolbar hands the export the
// store's own assignments, filter and all, and the stop sits at the head of its
// day, so the hotel printed above a morning flight (#2434).
// What the plan lists (planAssignments): the stop a booked night wrote sat at the
// head of its day, so the hotel printed above a morning flight (#2434).
const assigned = (assignments[String(day.id)] || [])
.filter((a: any) => a.accommodation_id == null)
.sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0))
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
const notes = (dayNotes || []).filter(n => n.day_id === day.id).slice()
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
const cost = dayCost(assignments, day.id, loc, tripCur, fxRates)
@@ -233,6 +233,81 @@ describe('PlaceFormModal', () => {
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-018d: a key alone is not enough: with Amap or OpenStreetMap picked the list offers nothing', async () => {
// The server only honours the request while Google holds the keyed slot;
// under another provider the link would re-run the same search and stay.
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true, placesProvider: 'openstreetmap' });
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }),
),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-018e: the link sends the query the list came from, not what the field holds by then', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [], source: 'trek-places' })),
http.post('/api/maps/search', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
// The list stays while the field is retyped; the line still means this list.
await user.clear(searchInput);
await user.type(searchInput, 'Kyoto');
await user.click(screen.getByRole('button', { name: /Search Google instead/ }));
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
expect(bodies).toHaveLength(2);
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
});
it('FE-PLANNER-PLACEFORM-018f: the link still works after the field was cleared', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/maps/search', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
// An empty field used to make the click a silent no-op.
await user.clear(searchInput);
await user.click(screen.getByRole('button', { name: /Search Google instead/ }));
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
});
it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => {
const user = userEvent.setup();
server.use(
@@ -18,7 +18,7 @@ import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isMapUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError'
import { offersGoogleRetry, sourceLabelFor } from '../../utils/placeSource'
import { offersGoogleRetry, selectGoogleHoldsSlot, sourceLabelFor } from '../../utils/placeSource'
import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -196,7 +196,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const placesSessionRef = useRef(new PlacesSession())
const toast = useToast()
const { t, language, locale } = useTranslation()
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
const { placesEnrichEnabled } = useAuthStore()
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const can = useCanDo()
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
const tripObj = useTripStore((s) => s.trip)
@@ -397,12 +398,15 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
const handleMapsSearch = async (provider?: 'google') => {
if (!mapsSearch.trim()) return
// The retry sends the query the list came from, not the field: the list
// stays on screen while the field is edited or cleared, and the line under
// it promises the same query.
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : mapsSearch.trim()
if (!trimmed) return
setIsSearchingMaps(true)
try {
// A pasted Google Maps or Amap link resolves server-side into a place
const trimmed = mapsSearch.trim()
if (isMapUrl(trimmed)) {
if (!provider && isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
setForm(prev => ({
@@ -419,8 +423,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
return
}
}
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint, provider)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
const result = await mapsApi.search(trimmed, language, locationBiasPoint, provider)
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
setMapsResults(result.places || [])
setSearchSource(result.source || '')
} catch (err: unknown) {
@@ -760,7 +764,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
language,
locale,
timeFormat,
hasMapsKey,
googleAnswers,
placesEnrichEnabled,
can,
tripObj,
@@ -838,7 +842,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
toast,
t,
language,
hasMapsKey,
googleAnswers,
placesEnrichEnabled,
can,
tripObj,
@@ -1005,8 +1009,9 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
{/* The index answers first and Google only when it finds nothing, so a
list with the wrong place on it never reaches Google by itself. One
quiet line under the list sends the same query there, on an instance
that has a key and for a list Google did not already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && (
where Google holds the key slot and for a list Google did not
already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
<button
type="button"
onClick={() => handleMapsSearch('google')}
@@ -196,9 +196,9 @@ export function PlacesHeader(S: SidebarState) {
{/* Says out loud what the count above already narrowed to.
The map has followed the open day on this filter since #2024, and until now
nothing anywhere said so: the pool read 55, the map drew five, and the honest
conclusion was that the map was broken. It sits under the tabs rather than on
the map because a chip over the canvas is unreachable on a phone, which is
where this was reported from. */}
conclusion was that the map was broken. It sits under the filter select
rather than on the map because a chip over the canvas is unreachable on a
phone, which is where this was reported from. */}
{dayScoped && (
<div
className="border border-edge-faint bg-surface-tertiary text-content-secondary"
@@ -96,7 +96,8 @@ describe('TripExportModal', () => {
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
trip, days, showServiceStops: true,
dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
})
})
@@ -6,6 +6,7 @@ import { IcsSubscribeModal } from './IcsSubscribeModal'
import { useToast } from '../shared/Toast'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
import { useSettingsStore } from '../../store/settingsStore'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
/**
* What a GPX download can carry. Worded by what someone wants on their device
@@ -61,6 +62,9 @@ export function TripExportModal({
// The PDF is built outside React, so it cannot read this itself (#2066).
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
// The export gets the store's assignments and applies the day plan's own filter to
// them, so it needs the same switch the plan reads.
const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, tripId)
const fileBase = trip?.title || 'trip'
// Shared tail of every download: Firefox and Safari cancel the download when
@@ -87,7 +91,7 @@ export function TripExportModal({
// exported. A missing chunk lands in the catch and shows the same error
// the export already had.
const { downloadTripPDF } = await import('../PDF/TripPDF')
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit })
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit, showServiceStops })
onClose()
} catch (e) {
console.error('PDF error:', e)
@@ -80,6 +80,31 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
}
}
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
legIndex: 0,
intoLegKm: 182,
drivenMeters: 182000,
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
// traveller's own setting, so a band printing it would read the same figure on
// every leg of the day.
sinceKm: 600,
lat: 52.4,
lng: 10.2,
...over,
})
/** Idle unless a case says which part of the search it is standing in. */
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
openFor: null,
loading: false,
outcome: null,
results: [],
offered: [],
ask: vi.fn(),
close: vi.fn(),
...over,
})
describe('RoadtripSidebar', () => {
/**
* The value the summary head pairs with this label. The same words also name the chips
@@ -585,11 +610,16 @@ describe('RoadtripSidebar', () => {
expect(onReorderStop).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car', () => {
const ferry = leg({ mode: 'ferry' })
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [ferry] })] })} />)
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car, a ride keeps its own', () => {
const unknown = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [leg({ mode: 'hovercraft' })] })] })} />)
expect(unknown.container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
unknown.unmount()
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
// A ferry leg is a booked ride since the carrier seams (#2429), not a drive
// whose mode nobody drew an icon for.
const ride = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [leg({ mode: 'ferry' })] })] })} />)
expect(ride.container.querySelector('svg.lucide-sailboat')).toBeInTheDocument()
expect(ride.container.querySelector('svg.lucide-car-front')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
@@ -706,31 +736,6 @@ describe('RoadtripSidebar', () => {
* point the tank ran dry. A station offered at the warning is one the car cannot reach.
*/
describe('the leg the tank runs out on', () => {
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
legIndex: 0,
intoLegKm: 182,
drivenMeters: 182000,
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
// traveller's own setting, so a band printing it would read the same figure on
// every leg of the day.
sinceKm: 600,
lat: 52.4,
lng: 10.2,
...over,
})
/** Idle unless a case says which part of the search it is standing in. */
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
openFor: null,
loading: false,
outcome: null,
results: [],
offered: [],
ask: vi.fn(),
close: vi.fn(),
...over,
})
const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
osm_id: `osm-${name}`,
name,
@@ -1179,6 +1184,54 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
})
/** The card a ride lands on, joined to the card before it by the ride itself. */
const landingDay = (arrivingLeg: RouteSegment) => {
const arrival = terminal('arrival')
return day({
dayId: 2,
dayNumber: 2,
stops: [{ ...arrival, time: '07:00', carrier: { ...arrival.carrier!, at: '07:00' } }, stop({ assignmentId: 2, name: 'Munich' })],
legs: [leg()],
arrivingLeg,
schedule: {
entries: [
{ arrival: '07:00', departure: '07:00', anchored: true, dayOffset: 0 },
{ arrival: '08:00', departure: '08:00', anchored: false, dayOffset: 0 },
],
warnings: [],
},
})
}
it('FE-ROADTRIP-SIDEBAR-054: the join into the day a ride lands on is the ride, not a car driving no distance', () => {
const flown = leg({ mode: 'flight', distance: 0, duration: 9 * 3600, distanceText: '', durationText: '9 h' })
wrap(<RoadtripSidebar routes={routes({ days: [landingDay(flown)], totalStops: 1 })} />)
expect(screen.getByText('LH 2020 · 9 h')).toBeInTheDocument()
expect(screen.queryByText(/0 m in 9 h/)).not.toBeInTheDocument()
// Under the booking's own icon, not the car the road out of the airport wears.
const band = screen.getByText('LH 2020 · 9 h').parentElement!
expect(band.querySelector('svg.lucide-plane')).toBeInTheDocument()
expect(band.querySelector('svg.lucide-car-front')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-055: a ride without a timetable still names the booking rather than passing for a hop', () => {
const untimed = leg({ mode: 'flight', distance: 0, duration: 0, distanceText: '', durationText: '' })
wrap(<RoadtripSidebar routes={routes({ days: [landingDay(untimed)], totalStops: 1 })} />)
expect(screen.getByText('LH 2020')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-056: the tank starts afresh after a flight, and the road out of the arrival can be the one it runs out on', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [{ ...flightDay(), dryPoints: [dry({ legIndex: 2 })] }], totalStops: 2 })} refuel={search()} />,
)
// Hamburg, the ride block, Munich: the band hangs under the block, where the road
// out of the arrival leaves, and nowhere else.
const rows = container.querySelectorAll('li')
expect(rows).toHaveLength(3)
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
expect(within(rows[1] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
})
})
describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () => {
@@ -1194,9 +1247,9 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
const booking = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => {
const rentalDay = () => {
const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
const rental = day({
return day({
stops,
legs: [leg(), leg(), leg()],
schedule: {
@@ -1209,8 +1262,11 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
warnings: [],
},
})
}
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => {
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [rental], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
wrap(<RoadtripSidebar routes={routes({ days: [rentalDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
expect(screen.getByText('Pick-up 09:00')).toBeInTheDocument()
expect(screen.getByText('Return 11:30')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
@@ -1223,6 +1279,15 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
expect(screen.getAllByLabelText('Other ways')).toHaveLength(2)
})
it('FE-ROADTRIP-SIDEBAR-057: a tank that empties on the road out of the pick-up desk is offered a fill-up under the desk', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [{ ...rentalDay(), dryPoints: [dry({ legIndex: 0 })] }], totalStops: 2 })} refuel={search()} />,
)
const rows = container.querySelectorAll('li')
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
expect(within(rows[0] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-053: a table pinned to a stop, or booked at its place on its day, hangs under the stop as a chip that opens it; one for no stop is listed under the day', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
@@ -1233,7 +1298,7 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
booking({ id: 15, title: 'Hotel Hafen', type: 'hotel', place_id: 10 }),
]
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} canEditBookings />)
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
expect(screen.getByText('19:30')).toBeInTheDocument()
expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
@@ -1247,4 +1312,21 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
// The chips are the stop's, not stops: the count stays at two.
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-058: for a reader who may not edit bookings, a table is a plain chip while a taxi still opens its detail view', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
booking({ id: 12, title: 'Taxi zum Hafen', type: 'taxi', reservation_time: '2026-10-05T18:30', assignment_id: 1 }),
]
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
// The table is there to read, not a button that does nothing; the taxi is a button.
expect(screen.getByText('Tisch Bullerei').closest('button')).toBeNull()
expect(screen.getByText('Taxi zum Hafen').closest('button')).not.toBeNull()
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(onOpenBooking).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Taxi zum Hafen'))
expect(onOpenBooking).toHaveBeenCalledWith(12)
})
})
@@ -23,6 +23,7 @@ import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker'
import { useVehicleRange } from './useVehicleRange'
import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared'
import { isCarrierMode, type CarrierTerminal } from '@trek/shared/roadtrip'
import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { SpillMark } from './nightSpill'
import { dayColor } from './dayColors'
@@ -31,7 +32,7 @@ import { FS } from './typeScale'
import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop'
import { carrierIcon, rideText, terminalLine } from './carrierRide'
import { bookingOpens, carrierIcon, rideText, terminalLine } from './carrierRide'
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
import type { Reservation } from '../../types'
import type { StayDraft } from './RoadtripStayModal'
@@ -115,6 +116,12 @@ interface RoadtripSidebarProps {
* the table or the tickets under a stop. Absent leaves them all as plain text.
*/
onOpenBooking?: (reservationId: number) => void
/**
* Whether the reader may open a table's or a ticket's editor. A transport has a detail
* view anybody may look at; the other chips have only their editor, and without the
* right to it they stay plain rather than being a button that does nothing (#2012).
*/
canEditBookings?: boolean
}
const MODE_ICON: Record<string, LucideIcon> = {
@@ -683,8 +690,10 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
)
}
function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: {
leg: RouteSegment | undefined
/** The booking, when the leg is a ride and not a road: the join into the day it lands on. */
carrier?: CarrierTerminal
/** Asks for other ways of driving this leg. Absent means the route is not editable. */
onAskAlternatives?: () => void
alternativesOpen?: boolean
@@ -692,10 +701,14 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const mode = leg?.mode ?? 'driving'
const Icon = mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront
// A ride reads as the ride block's pill does: the booking and its minutes under the
// booking's icon. Through the road branches it was a car driving no distance for nine
// hours, and a ride without a timetable was short enough to pass for a hop.
const ride = isCarrierMode(mode)
const Icon = ride ? carrierIcon(mode) : mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront
// A hop (the hire desk beside the terminal) keeps the line and drops the pill: there is
// nothing to say about it and no other way to drive it.
if (isHop(leg)) {
if (!ride && isHop(leg)) {
return (
<div className="grid" style={RAIL_GRID}>
<span className="relative z-[1] flex min-h-[10px] flex-col items-center" aria-hidden>
@@ -711,12 +724,14 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
<>
<Icon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
{leg
? t('roadtrip.leg.driveText', {
distance: formatDistance(leg.distance / 1000, distanceUnit),
time: formatDurationShort(leg.duration),
})
: t('roadtrip.leg.pending')}
{!leg
? t('roadtrip.leg.pending')
: ride
? rideText(carrier, leg)
: t('roadtrip.leg.driveText', {
distance: formatDistance(leg.distance / 1000, distanceUnit),
time: formatDurationShort(leg.duration),
})}
</span>
</>
)
@@ -782,27 +797,22 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
* the time the chain wants the traveller there, which for a departure is a check-in ahead
* of the timetable. The whole row opens the booking.
*/
function TerminalStop({ stop, entry, late, selected, continues, starts, onOpen }: {
function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
stop: RoadtripStop
entry: ScheduleEntry | undefined
late: ScheduleWarning[]
selected: boolean
continues: boolean
starts?: boolean
onOpen?: () => void
}): React.ReactElement {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const carrier = stop.carrier!
const Icon = carrierIcon(carrier.type)
const line = terminalLine(carrier, t, is12h)
const Icon = carrierIcon(stop.carrier!.type)
return (
<button
type="button"
onClick={onOpen}
disabled={!onOpen}
aria-label={onOpen ? t('roadtrip.ride.open') : undefined}
aria-current={selected ? 'true' : undefined}
className="group grid w-full rounded-lg text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent disabled:cursor-default"
style={RAIL_GRID}
>
@@ -813,38 +823,17 @@ function TerminalStop({ stop, entry, late, selected, continues, starts, onOpen }
</span>
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
</span>
<span
className={`flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors ${
selected ? 'bg-surface-selected' : 'group-hover:bg-surface-hover'
}`}
>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content"
style={{ fontSize: FS.name }}
>
<span className="min-w-0 break-words">{stop.name}</span>
{carrier.code ? (
<span className="shrink-0 font-geist font-medium text-content-faint" style={{ fontSize: FS.meta }}>{carrier.code}</span>
) : null}
</span>
<span className="flex flex-wrap items-center gap-1">
{line ? (
<span className="text-content-muted" style={{ fontSize: FS.meta }}>{line}</span>
) : null}
{late.map(w => <LateBadge key={w.code} late={w} />)}
</span>
</span>
{entry?.arrival ? <Arrival entry={entry} /> : null}
<span className="min-w-0 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
<RideEnd stop={stop} entry={entry} late={late} />
</span>
</button>
)
}
/**
* One end of a ride inside the ride's block: the terminal's name and code, the
* timetable's clock under it, and on the right the time the chain has the traveller
* there. The same three things the lone terminal row shows, without a row of its own.
* One end of a ride: the terminal's name and code, the timetable's clock under it, and
* on the right the time the chain has the traveller there. Inside the ride's block for
* a same-day ride, and the whole of a lone terminal's row.
*/
function RideEnd({ stop, entry, late }: {
stop: RoadtripStop
@@ -856,7 +845,7 @@ function RideEnd({ stop, entry, late }: {
const line = terminalLine(stop.carrier!, t, is12h)
return (
<span className="flex min-w-0 items-start gap-2">
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
<span className="min-w-0 break-words">{stop.name}</span>
{stop.carrier!.code ? (
@@ -932,9 +921,11 @@ function RideBlock({ departure, arrival, entries, late, seg, continues, starts,
* Under the stop's button rather than inside it, because a button cannot hold buttons.
* The rail keeps its line through the row so the chain reads unbroken.
*/
function BookingChips({ bookings, continues, onOpen }: {
function BookingChips({ bookings, continues, canEdit, onOpen }: {
bookings: Reservation[]
continues: boolean
/** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
canEdit: boolean
onOpen?: (reservationId: number) => void
}): React.ReactElement {
const { t } = useTranslation()
@@ -956,7 +947,7 @@ function BookingChips({ bookings, continues, onOpen }: {
</>
)
const chip = 'inline-flex h-[20px] max-w-full items-center gap-1 rounded-md border border-edge bg-surface-card px-1.5 text-content-secondary'
return onOpen ? (
return onOpen && bookingOpens(r, canEdit) ? (
<Tooltip key={r.id} label={t('roadtrip.ride.open')}>
<button
type="button"
@@ -1517,7 +1508,7 @@ function SpillBlock({ spill, children }: {
* move, a stay edit or a refuel offer still names the day the server knows it by. See
* `nightSpill.ts`.
*/
function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, dayOrder, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
day: RoadtripDay
/** Folded down to the header, and off the map with it. */
@@ -1532,7 +1523,10 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives']
openAlternatives?: RoadtripSidebarProps['openAlternatives']
onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
canEditBookings?: boolean
reservations?: Reservation[]
/** A day's place in the trip by its id, so a booking spanning days is seen on the days between. */
dayOrder?: (dayId: number) => number | null
onEditStay?: RoadtripSidebarProps['onEditStay']
onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
onSetStopFill?: RoadtripSidebarProps['onSetStopFill']
@@ -1549,7 +1543,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
// Which disc the picker hangs under, and for which stop. One at a time: two open
// popovers over the same rail is two answers to one question.
const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const bookings = useMemo(() => dayBookings(day, reservations ?? []), [day, reservations])
const bookings = useMemo(() => dayBookings(day, reservations ?? [], dayOrder), [day, reservations, dayOrder])
const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const { t, language } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
@@ -1574,16 +1568,36 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
*/
const latenessAt = (i: number): ScheduleWarning[] =>
day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
/**
* The band for the tank running out on the leg leaving `legIndex`, whichever row owns
* that leg: a stop, a night, a terminal. Only once the day has finished routing: the
* warnings are republished after every routing task and the early ones are wrong, so
* an offer that appears and moves while the trip loads reads as a fault.
*
* Keyed by the card's day, not the stop's stored one. This is the key the open search
* is filed under, and the hook is asked with the same number. A band inside a borrowed
* stretch had them differ, so the results arrived, drew on the map, and the band they
* belonged to never opened.
*/
const refuelBandsFor = (legIndex: number): React.ReactElement[] | null =>
refuel && !loading
? (day.dryPoints ?? []).filter(dry => dry.legIndex === legIndex).map(dry => (
<RefuelBand
key={`dry-${dry.legIndex}`}
dry={dry}
dayId={day.dayId}
refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined}
/>
))
: null
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement | null => {
if (stop.automaticNight) return (
<li key={stop.assignmentId}>
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
{i < last && day.legs[i]?.distance !== 0 ? <DriveBand leg={day.legs[i]} /> : null}
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === i).map(dry => (
<RefuelBand key={`dry-${dry.legIndex}`} dry={dry} dayId={day.dayId} refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
)) : null}
{refuelBandsFor(i)}
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
)) : null}
@@ -1626,15 +1640,20 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
stop={stop}
entry={day.schedule.entries[i]}
late={lateness}
selected={false}
continues={i < last}
starts={i === 0}
onOpen={open}
/>
)}
{tail < last && (!after?.automaticNight || day.legs[tail]?.distance !== 0) ? (
<DriveBand leg={day.legs[tail]} />
/* The row the road leaves from carries the booking whenever that road is a
ride itself (a departure terminal seated mid-day), and the band needs it
to say more than a bare icon. */
<DriveBand leg={day.legs[tail]} carrier={day.stops[tail]?.carrier} />
) : null}
{/* The tank starts afresh after a flight and full at a pick-up desk, and the
first road out of either can be the one that empties it. */}
{refuelBandsFor(tail)}
{tail < last ? (day.legVias[tail] ?? []).map((via, vi) => (
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
)) : null}
@@ -1708,7 +1727,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
/>
)}
{bookings.atStop.has(i) ? (
<BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} onOpen={onOpenBooking} />
<BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
) : null}
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
<DriveBand
@@ -1717,28 +1736,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
/>
) : null}
{/* Only on the leg the fuel actually runs out on, and only once the day has
finished routing: the warnings are republished after every routing task
and the early ones are wrong, so an offer that appears and moves while
the trip loads reads as a fault. */}
{refuel && !loading
? (day.dryPoints ?? [])
.filter(dry => dry.legIndex === i)
.map(dry => (
<RefuelBand
key={`dry-${dry.legIndex}`}
dry={dry}
// The card's day, not the stop's stored one. This is the key the open
// search is filed under, and the hook is asked with the same number —
// a band inside a borrowed stretch had them differ, so the results
// arrived, drew on the map, and the band they belonged to never opened.
dayId={day.dayId}
refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined}
/>
))
: null}
{refuelBandsFor(i)}
{/* After the band, because a plugin halt happens on the drive it describes
rather than before setting off. */}
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
@@ -1755,18 +1753,15 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
* Built rather than mapped straight through, because a spill is drawn inside a block of
* its own and a block cannot be opened halfway down a `map`.
*/
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => (
<React.Fragment key={stop.assignmentId}>
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === -i - 1).map(dry => (
<li key={`inbound-${i}`}>
<RefuelBand dry={dry} dayId={day.dayId} refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
</li>
)) : null}
{renderStopContent(stop, i)}
</React.Fragment>
)
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => {
const inbound = refuelBandsFor(-i - 1)
return (
<React.Fragment key={stop.assignmentId}>
{inbound?.length ? <li key={`inbound-${i}`}>{inbound}</li> : null}
{renderStopContent(stop, i)}
</React.Fragment>
)
}
const runs: { spill: SpillMark | null; from: number; to: number }[] = []
{
let at = 0
@@ -1909,7 +1904,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
happens. Without it a morning that starts at 08:11 after a stay that ended at
08:00 looks like eleven minutes went missing. A day whose first stop crossed
over instead gets that road under its own block, so this is left out there. */}
{day.arrivingLeg ? <DriveBand leg={day.arrivingLeg} /> : null}
{/* A day that opens on an arrival terminal is joined by the ride, and the
terminal carries the booking the band names. */}
{day.arrivingLeg ? <DriveBand leg={day.arrivingLeg} carrier={day.stops[0]?.carrier} /> : null}
{runs.map(run => (
run.spill ? (
<SpillBlock key={`spill-${run.from}`} spill={run.spill}>
@@ -1930,7 +1927,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
<span aria-hidden />
<span className={`${STAT_LABEL} px-1.5`} style={{ fontSize: FS.micro }}>{t('roadtrip.bookings.loose')}</span>
</div>
<BookingChips bookings={bookings.loose} continues={false} onOpen={onOpenBooking} />
<BookingChips bookings={bookings.loose} continues={false} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
</li>
) : null}
</ol>
@@ -2033,7 +2030,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
* them is a pill just for being a number.
*/
export default function RoadtripSidebar({
routes, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
routes, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
collapsedDayIds, onToggleDay, onFocusPoint,
}: RoadtripSidebarProps): React.ReactElement {
@@ -2043,6 +2040,13 @@ export default function RoadtripSidebar({
const [from, setFrom] = React.useState<DragState['from']>(null)
const [dropAt, setDropAt] = React.useState<DragState['dropAt']>(null)
const drag: DragState = { from, setFrom, dropAt, setDropAt }
// Where each day stands in the trip, so a booking that spans several days is listed on
// the days between its two ends as well, the way the day plan lists a three-day tour.
// Quiet days count: a span can run straight over one.
const dayOrder = useMemo(() => {
const numbers = new Map([...routes.days, ...routes.quietDays].map(d => [d.dayId, d.dayNumber]))
return (dayId: number): number | null => numbers.get(dayId) ?? null
}, [routes.days, routes.quietDays])
// Nothing to total up, so nothing pretends to: no "0 km" standing above "No route yet".
if (routes.days.length === 0) {
@@ -2092,7 +2096,9 @@ export default function RoadtripSidebar({
onAskAlternatives={onAskAlternatives}
openAlternatives={openAlternatives}
onOpenBooking={onOpenBooking}
canEditBookings={canEditBookings}
reservations={reservations}
dayOrder={dayOrder}
onEditStay={onEditStay}
onSetStopKind={onSetStopKind}
onSetStopFill={onSetStopFill}
+16 -3
View File
@@ -1,12 +1,13 @@
import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
import { formatDurationShort } from './roadtripModel'
import { formatClockTime } from '../../utils/formatters'
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../types'
import type { Reservation, TranslationFn } from '../../types'
/**
* How a ride and its two terminals, and a hire car's two desks, read on the rail and in
* the phone chain (#2428).
* the phone chain (#2428), and which of the bookings under a stop open there.
*
* One module for both shells, because the two would otherwise carry the same icon table
* and the same three sentences each, and the duplication budget does not stretch to that.
@@ -39,9 +40,21 @@ export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h:
/**
* What the ride's pill says: the booking, then how long it takes when the timetable gives
* both ends. A ride with no minutes is still the booking, and says so without a duration.
* A ride leg met without its booking is just the minutes.
*/
export function rideText(carrier: CarrierTerminal, seg: RouteSegment | undefined): string {
export function rideText(carrier: Pick<CarrierTerminal, 'title'> | undefined, seg: RouteSegment | undefined): string {
const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '')
if (!carrier) return duration
return duration ? `${carrier.title} · ${duration}` : carrier.title
}
/**
* Whether a booking chip opens for this reader. A transport has a detail view anybody
* may look at; a table or a ticket has only its editor, the split the planner page makes
* when it opens one. Without the right to edit bookings the chip stays a plain chip,
* the way the place inspector's booking strip stays inert (#2012).
*/
export function bookingOpens(booking: Pick<Reservation, 'type'>, canEditBookings: boolean): boolean {
return TRANSPORT_TYPES.has(booking.type) || canEditBookings
}
+2 -2
View File
@@ -1,2 +1,2 @@
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround } from '@trek/shared/roadtrip'
export type { LatLng, Bbox, CorridorHit } from '@trek/shared/roadtrip'
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround, rideGaps, riddenRanges, inRiddenRange, drivenPieces } from '@trek/shared/roadtrip'
export type { LatLng, Bbox, CorridorHit, KmRange, RideGap } from '@trek/shared/roadtrip'
@@ -15,7 +15,7 @@ import {
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
// FE-RTROW-001 to FE-RTROW-044
// FE-RTROW-001 to FE-RTROW-055
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
@@ -604,4 +604,48 @@ describe('roadtripRows with a ride (#2428)', () => {
expect(firstStopOfPlace([flightDay()], -7)).toBeNull()
expect(firstStopOfPlace([flightDay()], 100)?.name).toBe('Bremen')
})
/** Bremen at 09:00, the airport at 12:20 for a 13:20 flight, landing at 14:30, nothing after. */
const endsOnRide = () =>
day([stop('Bremen'), terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
legs: [seg(0), ride],
schedule: { entries: [entry('09:00'), entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
})
/** The flight first, the hotel after it. */
const opensOnRide = () =>
day([terminal('Munich Airport', 'departure', '06:00'), terminal('Hamburg Airport', 'arrival', '07:10'), stop('Hotel')], {
legs: [ride, seg(1)],
schedule: { entries: [entry('05:00', { departure: '06:00' }), entry('07:10'), entry('09:00')], warnings: [] },
})
it('FE-RTROW-052: a day that ends on a ride arrives at the arrival terminal, one that opens on a ride starts at the departure', () => {
// The head card printed the clock of the stop before the flight as where the day ends,
// while the ride block right below it printed the landing.
expect(stageClocks(roadtripRows(endsOnRide()))).toEqual({ start: '09:00', arrive: '14:30' })
expect(stageClocks(roadtripRows(opensOnRide()))).toEqual({ start: '05:00', arrive: '09:00' })
})
it('FE-RTROW-053: a day that is nothing but a ride still has both clocks', () => {
const rideOnly = day([terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
legs: [ride],
schedule: { entries: [entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
})
expect(stageClocks(roadtripRows(rideOnly))).toEqual({ start: '12:20', arrive: '14:30' })
})
it('FE-RTROW-054: up next names the departure terminal before its check-in, and the arrival after take-off', () => {
// A lone terminal (a ride landing tomorrow) was already a candidate; the terminals
// inside a same-day ride were not, so the countdown skipped the airport.
expect(upNextStop(endsOnRide(), 10 * 60, true)?.row.stop.name).toBe('Munich Airport')
expect(upNextStop(endsOnRide(), 10 * 60, true)?.minutesUntil).toBe(140)
expect(upNextStop(endsOnRide(), 13 * 60 + 30, true)?.row.stop.name).toBe('Hamburg Airport')
expect(upNextStop(endsOnRide(), 15 * 60 + 1, true)?.minutesUntil).toBe(-31)
})
it('FE-RTROW-055: a stage that ends on a ride ends at its arrival terminal', () => {
const end = stageEnd(roadtripRows(endsOnRide()))
expect(end?.stop.name).toBe('Hamburg Airport')
expect(end?.time).toBe('14:30')
expect(stageEnd(roadtripRows(opensOnRide()))?.stop.name).toBe('Hotel')
})
})
@@ -255,6 +255,15 @@ export function destinationCount(day: RoadtripDay): number {
return day.stops.filter(s => !s.automaticNight && !s.carrier && !isServiceStopType(s.stopType)).length
}
/**
* The rows with each ride opened up into its two terminals, in place. A ride row holds
* them instead of two stop rows of their own, so a reader walking the rows for stops
* has to look inside it, or a day that ends on a flight ends at the stop before it.
*/
function unfoldRides(rows: readonly RoadtripRow[]): RoadtripRow[] {
return rows.flatMap(row => (row.kind === 'ride' ? [row.departure, row.arrival] : [row]))
}
/**
* The two clocks a stage is headed with: when it starts, and when you reach its last place.
*
@@ -284,7 +293,7 @@ export function destinationCount(day: RoadtripDay): number {
export function stageClocks(rows: readonly RoadtripRow[]): { start: string | null; arrive: string | null } {
let start: string | null = null
let arrive: string | null = null
for (const row of rows) {
for (const row of unfoldRides(rows)) {
if (row.kind === 'stop' && row.time) {
start ??= row.time
arrive = row.time
@@ -296,7 +305,8 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
}
/**
* The stop a stage ends on: the last one the chain draws, a service stop included.
* The stop a stage ends on: the last one the chain draws, a service stop or the arrival
* terminal of a ride included.
*
* The map half names it in its bar and opens it on a tap, so the name, the clock beside it
* and the sheet the tap brings up all come off this one row and cannot name three different
@@ -308,8 +318,9 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
* There is nothing for a tap to open then, and the bar says so by not being a button.
*/
export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i]
const unfolded = unfoldRides(rows)
for (let i = unfolded.length - 1; i >= 0; i--) {
const row = unfolded[i]
if (row.kind === 'stop') return row
}
return null
@@ -355,7 +366,7 @@ export function upNextStop(
isToday: boolean,
): { row: StopRow; minutesUntil: number } | null {
if (!day || !isToday) return null
const timed = roadtripRows(day)
const timed = unfoldRides(roadtripRows(day))
.filter((r): r is StopRow => r.kind === 'stop' && !r.service)
.map(row => ({ row, at: clockMinutes(row.time) }))
.filter((x): x is { row: StopRow; at: number } => x.at != null)
@@ -55,6 +55,23 @@ describe('dayBookings (#2428)', () => {
expect(dayBookings(day, [])).toEqual({ atStop: new Map(), loose: [] })
})
it('FE-STOPBOOKINGS-005: a booking spanning several days is on the days between as well, unless its type hides there', () => {
// Day ids out of order on purpose: the span is read by the days' position, not by id.
const order = (dayId: number) => ({ 5: 0, 1: 1, 3: 2 } as Record<number, number>)[dayId] ?? null
const tour = booking({ id: 1, title: 'Three-day tour', type: 'tour', day_id: 5, end_day_id: 3 })
const parked = booking({ id: 2, title: 'Parked', type: 'parking', day_id: 5, end_day_id: 3 })
const dinner = booking({ id: 3, title: 'Dinner', day_id: 5, end_day_id: 3, place_id: 110 })
const { atStop, loose } = dayBookings(day, [tour, parked, dinner], order)
expect(loose.map(r => r.title)).toEqual(['Three-day tour'])
// On the day between, a booking for a place the day drives to still hangs under it.
expect(atStop.get(1)!.map(r => r.title)).toEqual(['Dinner'])
// Its first and last day list the parked car as before.
expect(dayBookings({ ...day, dayId: 3 }, [parked], order).loose).toHaveLength(1)
// Without a day order, or for a day the order does not know, only the two ends count.
expect(dayBookings(day, [tour])).toEqual({ atStop: new Map(), loose: [] })
expect(dayBookings({ ...day, dayId: 9 }, [tour], order)).toEqual({ atStop: new Map(), loose: [] })
})
it('FE-STOPBOOKINGS-004: the chip wears the booking panel\'s icon and the clock the booking starts at', () => {
expect(bookingIcon('restaurant')).toBe(Utensils)
expect(bookingIcon('car')).toBe(Car)
+20 -1
View File
@@ -1,6 +1,7 @@
import { Ticket, type LucideIcon } from 'lucide-react'
import { isCarrierType, isRentalType } from '@trek/shared/roadtrip'
import { RES_ICONS } from '../Planner/DayPlanSidebar.constants'
import { hidesOnMiddleDay } from '../../utils/dayMerge'
import { splitReservationDateTime } from '../../utils/formatters'
import type { Reservation } from '../../types'
import type { RoadtripStop } from '@trek/shared/roadtrip'
@@ -42,9 +43,27 @@ function byClock(a: Reservation, b: Reservation): number {
return a.id - b.id
}
/**
* Whether a booking is on the day: it starts or ends there, or the day lies between the
* two, the way the day plan lists a three-day tour on its middle day as well. A type
* that has nothing to say on its middle days (a parked car) is left off them here too.
* Without a day order only the first and the last day of a span can be told.
*/
function spansDay(r: Reservation, dayId: number, dayOrder?: (dayId: number) => number | null | undefined): boolean {
if (r.day_id === dayId || (r.end_day_id != null && r.end_day_id === dayId)) return true
if (!dayOrder || r.day_id == null || r.end_day_id == null) return false
const here = dayOrder(dayId)
const first = dayOrder(r.day_id)
const last = dayOrder(r.end_day_id)
if (here == null || first == null || last == null) return false
return here > Math.min(first, last) && here < Math.max(first, last) && !hidesOnMiddleDay(r, dayId)
}
export function dayBookings(
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight'>[] },
reservations: readonly Reservation[],
/** A day's position in the trip by its id, so a booking spanning days is on the days between too. */
dayOrder?: (dayId: number) => number | null | undefined,
): DayBookings {
if (!reservations.length) return EMPTY
const atStop = new Map<number, Reservation[]>()
@@ -56,7 +75,7 @@ export function dayBookings(
// automatic night has no assignment anybody pinned to.
const real = (i: number): boolean => !stops[i]!.carrier && !stops[i]!.automaticNight
let index = r.assignment_id ? stops.findIndex((s, i) => real(i) && s.assignmentId === r.assignment_id) : -1
const onDay = r.day_id === day.dayId || (r.end_day_id != null && r.end_day_id === day.dayId)
const onDay = spansDay(r, day.dayId, dayOrder)
if (index < 0 && onDay && r.place_id) index = stops.findIndex((s, i) => real(i) && s.placeId === r.place_id)
if (index >= 0) {
const list = atStop.get(index) ?? []
@@ -283,4 +283,20 @@ describe('useCorridorPois', () => {
expect(plain.current.results.map(r => r.osm_id).sort()).toEqual(['on-the-road', 'under-the-flight'])
expect(plain.current.progress.total).toBeGreaterThan(result.current.progress.total)
})
it('FE-ROADTRIP-CORRIDOR-019: the gaps are a dependency like any other, so a memoised array keeps `search` and a fresh one remakes it', () => {
// The caller memoises the array per day; the hook does not key it on its contents
// behind the lint rule's back.
const gaps = [{ from: LONG[3], to: LONG[8] }]
const categories = ['fuel']
const { result, rerender } = renderHook(
({ options }: { options: { gaps: typeof gaps } }) => useCorridorPois(LONG, categories, 10, options),
{ initialProps: { options: { gaps } } },
)
const search = result.current.search
rerender({ options: { gaps } })
expect(result.current.search).toBe(search)
rerender({ options: { gaps: [...gaps] } })
expect(result.current.search).not.toBe(search)
})
})
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo'
import { useTranslation } from '../../i18n'
import { corridorTiles, projectOntoRoute, simplifyLine, sliceAtMeters, type Bbox, type LatLng } from './corridor'
import { corridorTiles, drivenPieces, inRiddenRange, projectOntoRoute, riddenRanges, simplifyLine, type Bbox, type LatLng } from './corridor'
import { DESKTOP_CORRIDOR_BUDGET, type CorridorBudget } from './corridorSearchModel'
import type { Poi } from '../Map/poiCategories'
@@ -93,14 +93,14 @@ export function useCorridorPois(
* there, and boxing that would search open sea, or the countryside under a flight
* path, and offer what it found as being on the way. Each pair is projected onto the
* spine and the stretch between the two is left out of the search, and a hit that
* lands in it is dropped.
* lands in it is dropped. Memoised by the caller: the array's identity is what
* recreates `search`, so a fresh one every render would recreate it every render.
*/
gaps?: { from: LatLng; to: LatLng }[]
},
): CorridorSearch {
const budget = options?.budget ?? DESKTOP_CORRIDOR_BUDGET
const gaps = options?.gaps
const gapKey = (gaps ?? []).map(g => `${g.from.lat.toFixed(5)},${g.from.lng.toFixed(5)}>${g.to.lat.toFixed(5)},${g.to.lng.toFixed(5)}`).join(';')
const { locale } = useTranslation()
const [results, setResults] = useState<CorridorPoi[]>([])
const [progress, setProgress] = useState({ done: 0, total: 0 })
@@ -150,33 +150,9 @@ export function useCorridorPois(
// Only the TILE line is cut, never the spine: every `alongKm` in a result is a
// distance along the spine, and so is every entry in `stopsAlongKm`. Cutting the
// spine would renumber both the moment the window moved, and a hit would land at the
// wrong position in the day's chain.
const tileLine = window ? sliceAtMeters(spine, window.fromKm * 1000, window.toKm * 1000) : spine
// The ridden stretches, as kilometres along the spine, in order.
const skipped = (gaps ?? [])
.map(gap => {
const a = projectOntoRoute(gap.from, spine)?.alongKm
const b = projectOntoRoute(gap.to, spine)?.alongKm
return a === undefined || b === undefined ? null : { fromKm: Math.min(a, b), toKm: Math.max(a, b) }
})
.filter((g): g is { fromKm: number; toKm: number } => g !== null && g.toKm > g.fromKm)
.sort((a, b) => a.fromKm - b.fromKm)
const inGap = (alongKm: number): boolean => skipped.some(g => alongKm > g.fromKm + 0.05 && alongKm < g.toKm - 0.05)
// The tile line cut around the gaps: one piece per driven stretch.
const pieces: LatLng[][] = []
if (skipped.length) {
const start = window?.fromKm ?? 0
const end = window?.toKm ?? Number.POSITIVE_INFINITY
let cursor = start
for (const gap of skipped) {
if (gap.fromKm > cursor) pieces.push(sliceAtMeters(spine, cursor * 1000, Math.min(gap.fromKm, end) * 1000))
cursor = Math.max(cursor, gap.toKm)
}
if (cursor < end) pieces.push(Number.isFinite(end) ? sliceAtMeters(spine, cursor * 1000, end * 1000) : sliceAtMeters(spine, cursor * 1000, Number.MAX_SAFE_INTEGER))
} else {
pieces.push(tileLine)
}
const allTiles = pieces.filter(piece => piece.length > 1).flatMap(piece => corridorTiles(piece, widthKm))
// wrong position in the day's chain. The MCP corridor tool cuts the same way.
const ridden = riddenRanges(spine, gaps ?? [])
const allTiles = drivenPieces(spine, ridden, window).flatMap(piece => corridorTiles(piece, widthKm))
const tiles = allTiles.slice(0, budget.maxTiles)
const startedAt = Date.now()
const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs
@@ -221,7 +197,7 @@ export function useCorridorPois(
for (const poi of data.pois) {
if (seen.has(poi.osm_id)) continue
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
if (!hit || hit.offRouteKm > widthKm || inGap(hit.alongKm)) continue
if (!hit || hit.offRouteKm > widthKm || inRiddenRange(ridden, hit.alongKm)) continue
seen.set(poi.osm_id, {
...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null,
opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null,
@@ -288,10 +264,7 @@ export function useCorridorPois(
setError(failures === jobs.length && failures > 0)
setLoading(false)
})()
// gapKey stands in for `gaps`: the same terminals mean the same gaps, and a fresh
// array every render would make every search a new function.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gapKey])
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gaps])
useEffect(() => () => abortRef.current?.abort(), [])
@@ -313,4 +313,37 @@ describe('useRoadtripCorridor', () => {
expect(result.current.insertIndexFor(beforeFirstStop)).toBe(1)
})
})
it('FE-ROADTRIP-CORRIDORSTATE-038: the leg between a departure terminal and its arrival is no section', () => {
// The search leaves the ride out and drops every hit inside it, so a section around
// its midpoint could only ever come back empty. The two terminals stay offered.
const terminal = (role: 'departure' | 'arrival', index: number, lat: number, lng: number) => ({
assignmentId: -3000000014 - index, ownerDayId: 1, ownerIndex: index, placeId: -7, name: role, lat, lng,
time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: null, at: null },
})
const spine = [
{ lat: 53.5, lng: 9.9 }, { lat: 53.0, lng: 11.0 }, { lat: 52.5, lng: 13.4 }, { lat: 52.0, lng: 14.5 },
]
useCorridorPois.mockReturnValue({ ...searchWith([]), spine })
const [first, last] = day().stops
const flightDay = day({
stops: [
first,
terminal('departure', 1, 53.0, 11.0),
terminal('arrival', 2, 52.5, 13.4),
{ ...last, assignmentId: 3, ownerIndex: 3, lat: 52.0, lng: 14.5 },
],
geometry: spine.map(p => [p.lat, p.lng] as [number, number]),
})
const { result } = renderHook(() => useRoadtripCorridor(routes([flightDay])))
// On the old code the list also carries 'leg:1', the midpoint of the flight.
expect(result.current.anchors.map(a => `${a.kind}:${a.index}`))
.toEqual(['stop:0', 'leg:0', 'stop:1', 'stop:2', 'leg:2', 'stop:3'])
// The same ride is what the search is told to leave out.
const asked = useCorridorPois.mock.calls[useCorridorPois.mock.calls.length - 1]
expect(asked[3].gaps).toEqual([{ index: 1, from: { lat: 53.0, lng: 11.0 }, to: { lat: 52.5, lng: 13.4 } }])
})
})
@@ -3,7 +3,7 @@ import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCor
import type { CorridorBudget } from './corridorSearchModel'
import { sectionAnchors, insertIndexForAlong } from './roadtripModel'
import type { SectionAnchor } from './roadtripModel'
import { projectOntoRoute, type LatLng } from './corridor'
import { projectOntoRoute, rideGaps, type LatLng } from './corridor'
import { useVehicleRange } from './useVehicleRange'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
@@ -121,19 +121,9 @@ export function useRoadtripCorridor(
// The rides on this day: between a departure terminal and its arrival the car is not
// on the road, so that stretch of the line is not searched (#2428).
const gaps = useMemo(() => {
if (!day) return []
const out: { from: LatLng; to: LatLng }[] = []
day.stops.forEach((stop, i) => {
const next = day.stops[i + 1]
if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) {
out.push({ from: { lat: stop.lat, lng: stop.lng }, to: { lat: next.lat, lng: next.lng } })
}
})
return out
}, [day])
const rides = useMemo(() => (day ? rideGaps(day.stops) : []), [day])
const search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps }), [options, gaps]))
const search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps: rides }), [options, rides]))
/**
* Whether what the panel looks for has been decided yet.
@@ -207,8 +197,14 @@ export function useRoadtripCorridor(
* Resolved here rather than stored: `section` names an anchor, and its kilometre figure
* is looked up fresh, so changing the corridor width does not move the point the list
* is narrowed around.
*
* No break is planned in the middle of a ride: the search never covers it, so the leg
* between a departure terminal and its arrival is not offered as a section.
*/
const anchors = useMemo(() => sectionAnchors(stopsAlongKm), [stopsAlongKm])
const anchors = useMemo(
() => sectionAnchors(stopsAlongKm).filter(a => a.kind !== 'leg' || !rides.some(r => r.index === a.index)),
[stopsAlongKm, rides],
)
const anchorKm = useMemo(() => {
if (!section || !day || section.dayId !== day.dayId) return null
return anchors.find(a => a.kind === section.kind && a.index === section.index)?.alongKm ?? null
@@ -931,6 +931,49 @@ describe('a booking the traveller rides (#2428)', () => {
expect(result.current.days[1].arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600, distance: 0 })
})
it('FE-ROADTRIP-ROUTES-044: an edited timetable changes the ride minutes without a routing round, and the chain is not late for it', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
const stops: StopSpec[] = [{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 }, { id: 2, at: BERLIN, dwell: 0 }]
const { result, rerender } = renderHook(
({ reservations }) => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], reservations),
{ initialProps: { reservations: [flight()] } },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[0].legs[1]).toMatchObject({ mode: 'flight', duration: 70 * 60 })
// The same terminals, the same roads: the landing is now forty minutes after take-off.
rerender({ reservations: [flight({ reservation_end_time: '14:00' })] })
await waitFor(() => expect(result.current.days[0].legs[1]?.duration).toBe(40 * 60))
const d = result.current.days[0]
expect(d.legs[1]?.durationText).toBe('40 min')
expect(d.schedule.entries.map(e => e.arrival)).toEqual(['09:00', '12:20', '14:00', '15:00'])
expect(d.schedule.warnings.filter(w => w.code === 'late')).toEqual([])
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
})
it('FE-ROADTRIP-ROUTES-045: an overnight ride is the join between its cards, and a moved landing moves it too', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_connect_days: true } }))
const days = [day(1, 1), day(2, 2)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG, time: '18:00', dwell: 0 }]),
...map(2, [{ id: 2, at: BERLIN, dwell: 0 }]),
}
const overnight = (landing: string) => flight({ reservation_time: '22:00', reservation_end_time: landing, end_day_id: 2 })
const { result, rerender } = renderHook(
({ reservations }) => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], [], reservations),
{ initialProps: { reservations: [overnight('07:00')] } },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[1]?.arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600 })
// The ride is derived, not fetched: the router saw the two roads and nothing else.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
rerender({ reservations: [overnight('06:30')] })
await waitFor(() => expect(result.current.days[1]?.arrivingLeg?.duration).toBe(8.5 * 3600))
expect(result.current.days[1].schedule.entries[0].arrival).toBe('06:30')
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
})
it('FE-ROADTRIP-ROUTES-042: a booking without located terminals, or a taxi, changes nothing', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
@@ -1,5 +1,5 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, seatCarrierStops, standsAsDay, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, isCarrierMode, seatCarrierStops, standsAsDay, terminalAssignmentId, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
import { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
import { resolveLegMode } from '../Planner/legMode'
@@ -248,6 +248,31 @@ export function useRoadtripRoutes(
/** Days apart, for a ride that lands on a later day than it left. */
const dayNumberOf = (dayId: number): number => days.find(d => d.id === dayId)?.day_number ?? 0
/**
* The leg between the two terminals of every ride, keyed like a routed leg.
*
* Derived from the bookings rather than fetched, and kept out of the routing round on
* purpose: that round only runs when the geometry changes, and a ride's minutes come
* from its clocks. Filed inside the round, an edited departure time left the old
* minutes in place until something else re-routed, so the chain reached the arrival
* terminal late for a flight that had been moved earlier.
*/
const rideLegs = useMemo(() => {
const terminals = new Map<number, { stop: RoadtripStop; dayNumber: number }>()
for (const day of storedDays)
for (const stop of day.stops)
if (stop.carrier) terminals.set(stop.assignmentId, { stop, dayNumber: day.dayNumber })
const out: Record<string, RoutedLeg> = {}
for (const seam of rideSeams) {
if (seam.kind !== 'ride') continue
const dep = terminals.get(terminalAssignmentId(seam.reservationId, 'departure'))
const arr = terminals.get(terminalAssignmentId(seam.reservationId, 'arrival'))
if (!dep || !arr) continue
Object.assign(out, carrierLegsFor([dep.stop, arr.stop], seam.type, () => arr.dayNumber - dep.dayNumber, legKey))
}
return out
}, [storedDays, rideSeams])
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
// editing its notes must not fire a routing round.
const viaKey = useMemo(
@@ -305,13 +330,15 @@ export function useRoadtripRoutes(
* into a burst at the one host that refused it.
*/
const enqueueRun = (day: PlanDay, dayLegs: Record<string, RoutedLeg>, run: RoadtripStop[], mode: string): void => {
// A ride is no road: the leg between its terminals is the booking's minutes, and
// no router is asked. Still a task of its own, so it is published with the rest
// and a day that is nothing but a ride is not left waiting for a round that never
// comes.
const rides = carrierLegsFor(run, mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey)
if (rides) {
tasks.push(async () => { Object.assign(dayLegs, rides) })
// A ride is no road, so no router is asked. The two ends of a booked ride have
// their leg in `rideLegs`, which keeps step with an edited timetable; a terminal
// standing beside an ordinary stop is the other carrier-mode pair, and it still
// needs a leg of its own. Without one `computeSchedule` loses its cursor there and
// every arrival behind it goes blank. A run is one stored day, so its ends are
// never days apart.
if (isCarrierMode(mode)) {
const legs = carrierLegsFor(run, mode, () => 0, legKey) ?? {}
for (const [key, leg] of Object.entries(legs)) if (!rideLegs[key]) dayLegs[key] = leg
return
}
/** The same stops, as one request per pair. A pair has nothing left to split. */
@@ -416,22 +443,29 @@ export function useRoadtripRoutes(
// routes a single day; a road trip is every leg of every day at once, and the
// public routing hosts answer that with 429 after the first handful. Results are
// published as they land so the rail fills in instead of sitting empty.
const publish = (): void => {
setLegsByDay({ ...collected })
setSnapByDay({ ...collectedSnaps })
setMissedByDay({ ...collectedMisses })
}
void (async () => {
for (let i = 0; i < tasks.length; i++) {
if (controller.signal.aborted) return
const startedAt = performance.now()
await tasks[i]()
if (controller.signal.aborted) return
setLegsByDay({ ...collected })
setSnapByDay({ ...collectedSnaps })
setMissedByDay({ ...collectedMisses })
publish()
// Only pace what actually went out. RouteCalculator answers a repeat from its
// cache in well under a millisecond, and switching back into road trip mode is
// all repeats — waiting a second between those made a warm view feel broken.
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, controller.signal)
}
if (!controller.signal.aborted) setLoading(false)
if (controller.signal.aborted) return
// A plan that is nothing but rides asked for no road, and still replaces what
// the last round left behind.
if (!tasks.length) publish()
setLoading(false)
})()
return () => controller.abort()
@@ -455,9 +489,10 @@ export function useRoadtripRoutes(
// every routing round while `seamLegs` only ever grows, so a pair that used to sit on
// a day boundary and now sits inside one day keeps an answer nobody re-asks for, and
// that older answer was shaped by the vias of a stop that has since moved. Where both
// exist the day run is the newer of the two, so it is the one to believe.
return { ...seamLegs, ...out }
}, [plan, legsByDay, seamLegs])
// exist the day run is the newer of the two, so it is the one to believe. The rides
// last: a ride is never a road, whatever was fetched for its pair.
return { ...seamLegs, ...out, ...rideLegs }
}, [plan, legsByDay, seamLegs, rideLegs])
// Which date each stop is actually reached on. Nothing is written to make it so; see
// `nightSpill.ts`.
@@ -476,6 +511,8 @@ export function useRoadtripRoutes(
const seams = useMemo(() => {
const out: { from: RoadtripStop; to: RoadtripStop; dayId: number }[] = []
const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => {
// The two ends of one ride: the leg between them is the booking's, never a road.
if (rideLegs[legKey(from, to)]) return
// Routed as part of a day's own run — that request is rebuilt whenever its vias
// change, so there is nothing to catch up here.
if (legsByDay[from.ownerDayId]?.[legKey(from, to)]) return
@@ -509,7 +546,7 @@ export function useRoadtripRoutes(
}
}
return out
}, [chains, plan, quietDays, window, legsByDay, seamLegs, viasByDay, connectDays])
}, [chains, plan, quietDays, window, legsByDay, seamLegs, rideLegs, viasByDay, connectDays])
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';')
/** When the last seam request went out, across every run of the effect below. */
const lastSeamRequestAt = useRef(0)
@@ -520,6 +557,22 @@ export function useRoadtripRoutes(
void (async () => {
for (const seam of seams) {
if (controller.signal.aborted) return
const mode = resolveLegMode(
{ isPlace: true, leg_transport_mode: seam.from.legMode },
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
)
// No router knows a ride. A booked ride's own pair has its leg in `rideLegs` and
// never reaches here; what is left is a terminal seamed to an ordinary stop on
// another card, which is a join of no minutes rather than a road. Filed here,
// above the pacing block, so it spends no request slot.
if (isCarrierMode(mode)) {
const key = legKey(seam.from, seam.to)
const legs = carrierLegsFor([seam.from, seam.to], mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey)
const leg = legs?.[key]
if (leg) setSeamLegs(prev => ({ ...prev, [key]: { ...leg, shape: seamShape(seam.from, viasByDay) } }))
continue
}
// Paced before the request, against a clock that outlives this effect.
//
// Waiting AFTER one instead spaced nothing: storing a seam changes
@@ -534,19 +587,6 @@ export function useRoadtripRoutes(
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, controller.signal)
if (controller.signal.aborted) return
lastSeamRequestAt.current = performance.now()
const mode = resolveLegMode(
{ isPlace: true, leg_transport_mode: seam.from.legMode },
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
)
// A ride that lands on a later day is the one seam that is never a road: its
// terminals are on two cards, and the leg between them is the booking's minutes.
const ride = carrierLegsFor([seam.from, seam.to], mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey)
if (ride) {
const key = legKey(seam.from, seam.to)
setSeamLegs(prev => ({ ...prev, [key]: { ...ride[key], shape: seamShape(seam.from, viasByDay) } }))
continue
}
try {
// The via points on this seam, threaded in the same way the day runs thread
// theirs. Without them a seam is the one stretch of the trip a via cannot
@@ -1,4 +1,4 @@
// FE-RN-001 to FE-RN-014
// FE-RN-001 to FE-RN-016
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
@@ -209,4 +209,15 @@ describe('ReleaseNoticeModal', () => {
// The places card keeps the pill: that one is about the index.
expect(cards[0].querySelector('.trek-mark .pill-bg')).not.toBeNull()
})
it('FE-RN-016: the close button belongs to the panel, not the note', () => {
// Below 1080px the panel scrolls and the note is its second screen, so the
// stylesheet sticks the button to the panel's own top edge. That only works
// while it is a child of .rn-panel; put it back in the note and it scrolls a
// whole release half out of reach with every CSS assertion still green.
renderModal(releaseNotice())
const close = screen.getByRole('button', { name: 'Close' })
expect(close.parentElement?.classList.contains('rn-panel')).toBe(true)
expect(close.closest('.rn-note')).toBeNull()
})
})
@@ -64,6 +64,14 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
aria-describedby={bodyId}
className="rn-panel"
>
{/* A child of the panel, not the note: stacked, the panel scrolls and the
note is its second screen, so the button sticks to the panel's edge. */}
{notice.dismissible && (
<button type="button" className="rn-close" onClick={onDismiss} aria-label={t('common.close')}>
<X size={17} strokeWidth={2} />
</button>
)}
{/* ── Left: the release ─────────────────────────────────────────── */}
<div className="rn-release">
<div className="rn-release-grain" aria-hidden="true" />
@@ -127,12 +135,6 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
{/* ── Right: the note ───────────────────────────────────────────── */}
<div className="rn-note">
{notice.dismissible && (
<button type="button" className="rn-close" onClick={onDismiss} aria-label={t('common.close')}>
<X size={17} strokeWidth={2} />
</button>
)}
<div className="rn-note-body">
<div className="rn-note-eyebrow">{t(release.note.eyebrowKey)}</div>
<h3 className="rn-note-title">{t(release.note.titleKey)}</h3>
@@ -50,6 +50,7 @@
.rn-close { box-sizing: content-box; }
.rn-panel {
position: relative;
width: min(1420px, calc(100% - 2px));
max-height: 100%;
display: grid;
@@ -67,12 +68,60 @@
line-height: normal;
}
/* The panel's, not the note's: in the stacked layout below it has to outlast the
release half, which scrolls by first. */
.rn-close {
position: absolute;
top: 22px;
/* Logical, so the X sits at the reading end in Arabic too. With the physical
right it stayed over the release half there, which the note's palette below
is not made for, and the stacked layout put it at the other end anyway. */
inset-inline-end: 22px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-hover);
border: 1px solid var(--border-faint);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
z-index: 2;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.rn-close:hover { background: var(--bg-selected); color: var(--text-primary); }
/* Below the two-column breakpoint the halves stack and the panel scrolls. The
rows must go back to auto: with the single minmax row from above, the note took
the whole panel and the release half was left with nothing but its padding. */
@media (max-width: 1080px) {
.rn-panel { grid-template-columns: 1fr; grid-template-rows: auto; overflow-y: auto; }
.rn-panel { grid-template-columns: 1fr; grid-template-rows: auto auto; overflow-y: auto; }
.rn-release { grid-area: 1 / 1; }
.rn-note { grid-area: 2 / 1; }
.rn-release, .rn-release-inner, .rn-note-body { overflow: visible; }
/* The X used to sit in the note, a whole release half below the top edge, and
at a tablet's width the backdrop is gone too. As a grid item over both rows
it sticks 22px under the top edge for the entire scroll, over either half. */
.rn-close {
position: sticky;
grid-row: 1 / -1;
grid-column: 1;
justify-self: end;
align-self: start;
margin: 22px;
/* At the top of the scroll it hangs over the release half, which is dark in
both themes. The note's palette is a near-transparent fill and secondary
ink: in the light theme that is a slate glyph on near black, roughly 1.8:1,
so the only way to dismiss the notice on a tablet was invisible. An opaque
chip with primary ink carries over either half, the way .rn-support below
gets a solid band once it sticks. */
background: color-mix(in srgb, var(--bg-card) 92%, var(--text-primary));
border-color: var(--border-primary);
color: var(--text-primary);
box-shadow: 0 2px 8px oklch(0 0 0 / 0.25);
}
/* Once the note has scrolled into view, the ask stays at the bottom edge. The
band's usual tint is translucent, so it gets a solid one here. */
.rn-support {
@@ -560,26 +609,6 @@
min-height: 0;
}
.rn-close {
position: absolute;
top: 22px;
right: 22px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-hover);
border: 1px solid var(--border-faint);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
z-index: 2;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.rn-close:hover { background: var(--bg-selected); color: var(--text-primary); }
.rn-note-body {
flex: 1;
min-height: 0;
@@ -12,10 +12,108 @@ describe('release notice css', () => {
const notice = read('src/components/SystemNotices/releaseNotice.css')
const tokens = read('src/index.css')
const block = (selector: string): string => {
const at = notice.indexOf(selector)
const within = (source: string, selector: string): string => {
const at = source.indexOf(selector)
expect(at, `${selector} missing from releaseNotice.css`).toBeGreaterThan(-1)
return notice.slice(at, notice.indexOf('}', at) + 1)
return source.slice(at, source.indexOf('}', at) + 1)
}
const block = (selector: string): string => within(notice, selector)
// A half is named twice: indented in the stacked block, where it is only placed
// in the grid, and at the top level, where it is painted. Anchor on the newline
// to get the second.
const painted = (selector: string): string => within(notice, `\n${selector}`)
const stacked = (): string => {
const at = notice.indexOf('@media (max-width: 1080px)')
expect(at, 'the stacked block').toBeGreaterThan(-1)
return notice.slice(at, notice.indexOf('\n}', at))
}
// How many rules deep a position sits, comments taken out so a brace in prose
// cannot shift the count. Zero means the rule applies at every width.
const depthAt = (index: number): number => {
let depth = 0
for (const ch of notice.slice(0, index).replace(/\/\*[\s\S]*?\*\//g, '')) {
if (ch === '{') depth += 1
else if (ch === '}') depth -= 1
}
return depth
}
const decl = (rule: string, prop: string): string => {
const found = new RegExp(`(?:^|[;{\\n])\\s*${prop}:\\s*([^;]+);`).exec(rule)
expect(found, `${prop} missing from ${rule.slice(0, 44)}`).not.toBeNull()
return found![1].trim()
}
interface Rgba { r: number; g: number; b: number; a: number }
// The custom properties of one theme, so a declaration can be resolved the way
// the browser resolves it instead of being taken on trust.
const theme = (opener: string): Record<string, string> => {
const at = tokens.indexOf(opener)
expect(at, `${opener} missing from index.css`).toBeGreaterThan(-1)
const body = tokens.slice(at, tokens.indexOf('\n}', at))
const map: Record<string, string> = {}
for (const [, token, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) map[token] = value.trim()
return map
}
const rgba = (value: string, vars: Record<string, string>): Rgba => {
const v = value.trim()
const ref = /^var\((--[\w-]+)\)$/.exec(v)
if (ref) {
const token = vars[ref[1]]
expect(token, `${ref[1]} missing from index.css`).toBeDefined()
return rgba(token, vars)
}
const mix = /^color-mix\(in srgb,\s*(.+?)\s+([\d.]+)%,\s*(.+)\)$/.exec(v)
if (mix) {
const share = Number(mix[2]) / 100
const first = rgba(mix[1], vars)
const second = rgba(mix[3], vars)
const blend = (x: number, y: number): number => x * share + y * (1 - share)
return {
r: blend(first.r, second.r),
g: blend(first.g, second.g),
b: blend(first.b, second.b),
a: blend(first.a, second.a)
}
}
const hex = /^#([0-9a-f]{6})$/i.exec(v)
if (hex) {
return {
r: parseInt(hex[1].slice(0, 2), 16),
g: parseInt(hex[1].slice(2, 4), 16),
b: parseInt(hex[1].slice(4, 6), 16),
a: 1
}
}
const fn = /^rgba?\(([^)]+)\)$/.exec(v)
expect(fn, `${value} is not a colour this test can resolve`).not.toBeNull()
const parts = fn![1].split(',').map(Number)
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }
}
const over = (front: Rgba, back: Rgba): Rgba => ({
r: front.r * front.a + back.r * (1 - front.a),
g: front.g * front.a + back.g * (1 - front.a),
b: front.b * front.a + back.b * (1 - front.a),
a: 1
})
const contrast = (one: Rgba, other: Rgba): number => {
const luminance = (c: Rgba): number => {
const channel = (raw: number): number => {
const v = raw / 255
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b)
}
const [light, dark] = [luminance(one), luminance(other)].sort((a, b) => b - a)
return (light + 0.05) / (dark + 0.05)
}
const step = (name: string): number => {
@@ -47,4 +145,60 @@ describe('release notice css', () => {
expect(columns).toMatch(/flex-shrink:\s*0/)
expect(block('.rn-promise {')).toMatch(/overflow:\s*hidden/)
})
it('FE-COMP-RELEASENOTICECSS-004: stacked, the close button stays on screen for the whole scroll', () => {
// Below 1080px the panel scrolls as one column with the release half first.
// The X lived in the note, so on a tablet it was a whole release half below
// the fold, and at 900px and under there is no backdrop to tap instead. It
// is a grid item spanning both rows now, stuck to the panel's top edge; the
// base rule is absolute and must come first or it wins the cascade.
const at = notice.indexOf('@media (max-width: 1080px)')
const base = notice.indexOf('.rn-close {\n position: absolute')
expect(base, 'the absolute close rule').toBeGreaterThan(-1)
expect(at).toBeGreaterThan(base)
const close = within(stacked(), '.rn-close {')
expect(close).toMatch(/position:\s*sticky/)
expect(close).toMatch(/grid-row:\s*1 \/ -1/)
// Both halves need a definite cell, or auto-placement steps around the button.
expect(stacked()).toMatch(/\.rn-release \{ grid-area: 1 \/ 1; \}/)
expect(stacked()).toMatch(/\.rn-note \{ grid-area: 2 \/ 1; \}/)
expect(block('.rn-panel {')).toMatch(/position:\s*relative/)
// Sticky without an offset never sticks, and the offset is the base rule's:
// scope that into a width query of its own and the button silently scrolls
// away again with every assertion above still green.
expect(decl(notice.slice(base), 'top'), 'the close button has no offset to stick to').toMatch(/^\d+px$/)
expect(depthAt(base), 'the offset the sticky rule leans on sits inside a query').toBe(0)
})
it('FE-COMP-RELEASENOTICECSS-005: stacked, the close button reads over the release half too', () => {
// Stuck to the panel's top edge the X hangs over .rn-release, a brand panel
// that is dark in both themes, but it kept the palette it had on the white
// note: a near-transparent fill under secondary ink. Resolved against the real
// tokens that is a slate glyph on near black in the light theme, about 1.8:1,
// and below 1080px this button is the only way out of the notice. Both halves
// are one scroll, so the chip has to carry over either.
const gradient = decl(painted('.rn-release {'), 'background')
const backdrops = [...gradient.matchAll(/#[0-9a-f]{6}/gi)].map(match => match[0])
expect(backdrops.length, 'the release half paints no gradient stops').toBeGreaterThan(1)
backdrops.push(decl(painted('.rn-note {'), 'background'))
const themes = [
{ name: 'light', vars: theme(':root {') },
{ name: 'dark', vars: theme('.dark {') }
]
const states = [
{ name: 'at rest', rule: within(stacked(), '.rn-close {') },
{ name: 'hovered', rule: block('.rn-close:hover {') }
]
for (const { name, vars } of themes) {
for (const state of states) {
const glyph = rgba(decl(state.rule, 'color'), vars)
for (const behind of backdrops) {
const chip = over(rgba(decl(state.rule, 'background'), vars), rgba(behind, vars))
expect(contrast(glyph, chip), `${name}, ${state.name}, over ${behind}`).toBeGreaterThanOrEqual(4.5)
}
}
}
})
})
+6 -1
View File
@@ -1501,8 +1501,13 @@ img[alt="TREK"] {
}
.collab-link-chip__action:hover, .collab-link-chip__action:focus-visible { background: var(--bg-hover); color: var(--text-primary); }
.collab-link-chip__action--on { color: var(--accent); }
/* Fingers, not a pointer: the phone Collab tab mounts this same chip. 22px targets
a pixel apart put delete one slip away from pin, so the hit box grows to the
30px at a 34px pitch the phone Notes tab uses. The vertical margin gives the
height back, the chip stays 36px. */
@media (hover: none) {
.collab-link-chip__actions { opacity: 1; }
.collab-link-chip__actions { opacity: 1; gap: 4px; }
.collab-link-chip__action { width: 30px; height: 30px; margin: -4px 0; }
}
/* Markdown in Collab Notes */
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { AlertTriangle, ChevronDown, RefreshCw } from 'lucide-react'
import { adminApi, authApi } from '../../../api/client'
import { getApiErrorMessage } from '../../../types'
import { placesGoogleOnlyHint } from '../../../utils/placeSource'
import type { TranslationFn } from '../../../types'
import type { useAdmin } from '../../../pages/admin/useAdmin'
import MToggle from '../../components/MToggle'
@@ -353,7 +354,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
/>
<MAdminRow
title={t('admin.placesGoogleOnly.title')}
hint={t(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')}
hint={t(placesGoogleOnlyHint(hasMapsKey, placesProvider))}
trailing={
<MToggle
checked={placesGoogleOnly}
@@ -7,7 +7,7 @@ import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { photoUrl } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
@@ -388,7 +388,7 @@ export default function MJourneyDetail() {
onClick={() => openLightbox(gallery, i)}
className="relative aspect-square overflow-hidden rounded-[14px]"
>
{photo.media_type === 'video' && !photo.thumbnail_path ? (
{posterlessVideo(photo) ? (
<span className="block h-full w-full bg-[color:var(--m-ic)]" />
) : (
<img src={photoUrl(photo, 'thumbnail')} alt={photo.caption || ''} loading="lazy" className="h-full w-full object-cover" />
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { localIsoDate } from '../../../utils/localDate'
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff } from 'lucide-react'
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
@@ -16,7 +16,7 @@ import { getCurrentPositionOnce } from '../../../hooks/useGeolocation'
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
import { photoUrl, geoOnceErrorKey, isValidGeoPoint } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo, geoOnceErrorKey, isValidGeoPoint } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import JournalBody from '../../../components/Journey/JournalBody'
import { ProviderPicker, type ProviderPhotoGroup } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { journeyWeatherCategory, MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
@@ -33,6 +33,18 @@ interface LocationResult {
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
// A clip with no poster to show: the tinted tile and play mark the gallery grid
// gives it, since an <img> asked for its thumbnail draws the broken glyph (#2341).
function ClipTile() {
return (
<span className="absolute inset-0 flex items-center justify-center bg-[color:var(--m-ic)]">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur">
<Play size={12} className="ml-[1px]" fill="currentColor" />
</span>
</span>
)
}
interface MJourneyEntrySheetProps {
entry: JourneyEntry
galleryPhotos: GalleryPhoto[]
@@ -592,7 +604,11 @@ export default function MJourneyEntrySheet({
}
}}
>
<img src={photoUrl(gp)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
{posterlessVideo(gp) ? (
<ClipTile />
) : (
<img src={photoUrl(gp)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
)}
</button>
))}
{availableGalleryPhotos.length === 0 && (
@@ -610,7 +626,11 @@ export default function MJourneyEntrySheet({
<div className="mt-[10px] flex flex-wrap gap-2">
{photos.map((p, idx) => (
<div key={p.id} className="relative h-16 w-16 overflow-hidden rounded-[13px]">
<img src={photoUrl(p)} alt="" className="h-full w-full object-cover" />
{posterlessVideo(p) ? (
<ClipTile />
) : (
<img src={photoUrl(p)} alt="" className="h-full w-full object-cover" />
)}
{!readOnly && idx > 0 && photos.length > 1 && (
<button
type="button"
@@ -381,7 +381,11 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
dayOrderMap={planner.dayOrderMap}
reservations={planner.reservations}
showReservationStats={true}
visibleConnectionIds={planner.visibleConnections}
// On the stage the rides the chain seams in draw as their booking's own arc,
// toggled on or not, the way the desktop map does it in road trip mode. The
// ride's leg carries no geometry, so without this the road ended at the
// departure terminal and picked up again at the arrival with nothing between.
visibleConnectionIds={onStage ? planner.roadtripConnections : planner.visibleConnections}
// Transport overlay tap → the mobile transport detail sheet (desktop
// routes this through mapTransportDetail into the day sidebar instead).
onReservationClick={(rid: number) => shell.openSheet('transport', { reservationId: rid })}
@@ -3,7 +3,7 @@ import type { CSSProperties, ReactNode } from 'react'
import MDancingTrek from '../../../components/MDancingTrek'
import MIconBtn from '../../../components/MIconBtn'
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
import { carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
import { bookingOpens, carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
import { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings'
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
import { formatDistance } from '../../../../utils/units'
@@ -252,12 +252,15 @@ export function RtRideRow({ row, chrome, onOpen }: {
/**
* The bookings a stop carries, under its row (#2428): the table, the tickets, the tour,
* each a chip with the booking panel's icon, its name and the clock it starts at. Chips
* because a booking is a fact about the stop and not a stop of its own. Each opens its
* booking; without `onOpen` they are plain.
* because a booking is a fact about the stop and not a stop of its own. A chip that
* opens is a button; one this reader may not open stays a plain chip, the rule the rail
* follows too (`bookingOpens`).
*/
export function RtBookingChips({ bookings, chrome, onOpen }: {
export function RtBookingChips({ bookings, chrome, canEdit, onOpen }: {
bookings: Reservation[]
chrome: RowChrome
/** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
canEdit: boolean
onOpen?: (reservation: Reservation) => void
}) {
return (
@@ -275,7 +278,7 @@ export function RtBookingChips({ bookings, chrome, onOpen }: {
</>
)
const cls = 'inline-flex h-[28px] max-w-full items-center gap-[5px] rounded-full border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-[10px]'
return onOpen ? (
return onOpen && bookingOpens(r, canEdit) ? (
<button key={r.id} type="button" onClick={() => onOpen(r)} className={`${cls} text-start`}>{body}</button>
) : (
<span key={r.id} className={cls}>{body}</span>
@@ -1,4 +1,4 @@
import { Fragment, useMemo, useRef } from 'react'
import { Fragment, useCallback, useMemo, useRef } from 'react'
import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
import { showStopOnMap, useMRoadtrip } from './useMRoadtrip'
@@ -18,6 +18,7 @@ import { isRtlLanguage } from '../../../../i18n'
import type { MTripTabPanelProps } from '../MTripShell'
import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import { dayBookings } from '../../../../components/Roadtrip/stopBookings'
import { getDayOrder } from '../../../../utils/dayOrder'
import type { Reservation } from '../../../../types'
/**
@@ -84,22 +85,30 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
else shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
}
// Where each day stands in the trip, so a booking spanning several days is listed on
// the days between its ends as well.
const dayOrder = useCallback((dayId: number) => {
const stored = planner.days.find(d => d.id === dayId)
return stored ? getDayOrder(stored, planner.days) : null
}, [planner.days])
// The bookings under the stage's stops and the ones the stage has for no stop, keyed
// by the stop's assignment because the rows carry no index. Opened the way the place
// sheet opens its linked bookings: a transport in its sheet, anything else in its
// editor, and only for somebody allowed to.
// editor.
const bookings = useMemo(() => {
const byIndex = stage ? dayBookings(stage, planner.reservations) : null
const byIndex = stage ? dayBookings(stage, planner.reservations, dayOrder) : null
const atStop = new Map<number, Reservation[]>()
if (stage && byIndex) for (const [i, list] of byIndex.atStop) atStop.set(stage.stops[i]!.assignmentId, list)
return { atStop, loose: byIndex?.loose ?? [] }
}, [stage, planner.reservations])
}, [stage, planner.reservations, dayOrder])
// A chip that may not be opened is not a button in the first place (`bookingOpens`),
// so nothing is turned away silently here.
const canEditBookings = planner.can('reservation_edit', planner.trip)
const openBooking = (res: Reservation) => {
if (planner.TRANSPORT_TYPES.has(res.type)) {
shell.openSheet('transport', { reservationId: res.id })
return
}
if (!planner.can('reservation_edit', planner.trip)) return
planner.setEditingReservation(res)
planner.setShowReservationModal(true)
}
@@ -263,7 +272,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
})
: undefined}
/>
{chips && <RtBookingChips bookings={chips} chrome={chrome} onOpen={openBooking} />}
{chips && <RtBookingChips bookings={chips} chrome={chrome} canEdit={canEditBookings} onOpen={openBooking} />}
</Fragment>
)
}
@@ -315,7 +324,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
<span aria-hidden="true" />
<span className="mb-[6px] block font-geist text-[0.65625rem] font-semibold uppercase tracking-[0.08em] text-m-faint">{t('roadtrip.bookings.loose')}</span>
</div>
<RtBookingChips bookings={bookings.loose} chrome={chrome} onOpen={openBooking} />
<RtBookingChips bookings={bookings.loose} chrome={chrome} canEdit={canEditBookings} onOpen={openBooking} />
</div>
)}
</section>
@@ -4,6 +4,7 @@ import MSheet from '../../../components/MSheet'
import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeModal'
import { useTripStore } from '../../../../store/tripStore'
import { useSettingsStore } from '../../../../store/settingsStore'
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
import { useTranslation } from '../../../../i18n'
import { INNER_CLS, TileHeader } from './MTripSheetUi'
import type { MTripSheetsProps } from '../MTripShell'
@@ -20,6 +21,9 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
// The PDF is built outside React, so it cannot read this itself (#2066).
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
// Fed the way the desktop dialog feeds it: the store's assignments and the switch,
// and the export applies the day plan's filter itself, so both shells print the same.
const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, planner.tripId)
const open = shell.sheet?.id === 'export'
const dayNotes = useTripStore(s => s.dayNotes)
const [subscribeOpen, setSubscribeOpen] = useState(false)
@@ -44,7 +48,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
trip: planner.trip,
days: planner.days,
places: planner.places,
assignments: planner.assignments,
assignments: planner.storedAssignments,
categories: planner.categories,
dayNotes: flatNotes,
reservations: planner.reservations,
@@ -52,6 +56,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
locale,
timeFormat,
distanceUnit,
showServiceStops,
})
} catch (e) {
planner.toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`)
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, RotateCcw, Search } from 'lucide-react'
import { mapsApi } from '../../../../api/client'
import { useAuthStore } from '../../../../store/authStore'
import { offersGoogleRetry, sourceLabelFor } from '../../../../utils/placeSource'
import { offersGoogleRetry, selectGoogleHoldsSlot, sourceLabelFor } from '../../../../utils/placeSource'
import { recordPlacePick } from '../../../../api/placeShadow'
import { PlacesSession } from '../../../../utils/placesSession'
import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
@@ -98,7 +98,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
const [acSource, setAcSource] = useState('')
// What answered the last full search, for the line that offers Google instead.
const [searchSource, setSearchSource] = useState('')
const hasMapsKey = useAuthStore(s => s.hasMapsKey)
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const setResolving = useCallback(
(v: boolean) => {
@@ -173,7 +173,9 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
}
const handleSearch = async (provider?: 'google') => {
const trimmed = query.trim()
// The retry sends the query the list came from, as the desktop form does:
// the list stays on screen while the field is edited or cleared.
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : query.trim()
if (!trimmed) return
setSuggestions([])
@@ -187,7 +189,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
setResolving(true)
try {
if (isMapUrl(trimmed)) {
if (!provider && isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
onPick({
@@ -339,8 +341,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
)}
{/* The same quiet line the desktop form has: the index answers first and
Google only when it finds nothing, so this is how a list with the wrong
place on it reaches Google. */}
{results.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && (
place on it reaches Google, where Google holds the key slot. */}
{results.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
<button
type="button"
onClick={() => handleSearch('google')}
+39 -1
View File
@@ -1,4 +1,4 @@
// FE-PAGE-PUBLICJOURNEY-001 to FE-PAGE-PUBLICJOURNEY-010
// FE-PAGE-PUBLICJOURNEY-001 to FE-PAGE-PUBLICJOURNEY-023
import { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react';
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
@@ -580,4 +580,42 @@ describe('JourneyPublicPage', () => {
const badge = Array.from(shibuya.querySelectorAll('span')).find(s => /^\d+$/.test(s.textContent || ''));
expect(badge?.textContent).toBe('1');
});
// The share route resolves a photo the way the journey's own does, so a clip that
// came up without a poster 404s here too, and an <img> pointed at it drew the
// broken-image glyph on every shared layout (#2341).
it('FE-PAGE-PUBLICJOURNEY-023: a shared clip without a poster is a black tile, with a poster its poster', async () => {
const user = userEvent.setup();
const bare = { id: 200, entry_id: 20, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.mp4', thumbnail_path: null, media_type: 'video', caption: null };
const withPoster = { id: 201, entry_id: 21, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.mp4', thumbnail_path: 'journey/b.jpg', media_type: 'video', caption: null };
const entry = (id: number, photo: typeof bare) => ({
id, title: `Clip ${id}`, story: null, entry_date: '2026-03-15', entry_time: null,
location_name: null, location_lat: null, location_lng: null, mood: null, weather: null, pros_cons: null,
photos: [photo],
});
server.use(http.get('/api/public/journey/test-share-token', () => HttpResponse.json({
...mockJourneyData,
entries: [entry(20, bare), entry(21, withPoster)],
gallery: [
{ ...bare, journey_id: 1, shared: 1, sort_order: 0, created_at: 0 },
{ ...withPoster, journey_id: 1, shared: 1, sort_order: 1, created_at: 0 },
],
stats: { entries: 2, photos: 2, places: 0 },
})));
const bareSrc = 'img[src="/api/public/journey/test-share-token/photos/200/thumbnail"]';
const posterSrc = 'img[src="/api/public/journey/test-share-token/photos/201/thumbnail"]';
render(<JourneyPublicPage />);
await waitFor(() => expect(screen.getByText('Clip 20')).toBeInTheDocument());
expect(document.querySelector(bareSrc)).toBeNull();
expect(document.querySelector(posterSrc)).toBeInTheDocument();
expect(document.querySelectorAll('svg.lucide-play')).toHaveLength(2);
await user.click(screen.getAllByRole('button').find(btn => /gallery/i.test(btn.textContent || ''))!);
await waitFor(() => expect(document.querySelectorAll('.grid svg.lucide-play')).toHaveLength(2));
expect(document.querySelector(bareSrc)).toBeNull();
expect(document.querySelector(posterSrc)).toBeInTheDocument();
});
});
+68 -40
View File
@@ -31,6 +31,7 @@ import EmptyState from '../components/shared/EmptyState';
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
import { useTranslation } from '../i18n';
import { formatLocationName } from '../utils/formatters';
import { posterlessVideo } from './journeyDetail/JourneyDetailPage.helpers';
import { useJourneyPublic } from './journeyPublic/useJourneyPublic';
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
@@ -73,6 +74,13 @@ function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail'
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`;
}
// The share route answers 404 for the thumbnail of a clip that has no poster, and
// an <img> pointed at it draws the broken-image glyph (#2341). The black ground
// the layouts give a video stands in for the picture.
function ClipTile() {
return <span className="block h-full w-full bg-black" />;
}
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00');
return {
@@ -251,12 +259,14 @@ export default function JourneyPublicPage() {
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
>
<div className={`relative h-64 w-full ${photos[0].media_type === 'video' ? 'bg-black' : ''}`}>
<img
src={photoUrl(photos[0], token!, photos[0].media_type === 'video' ? 'thumbnail' : 'original')}
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
alt=""
/>
{!posterlessVideo(photos[0]) && (
<img
src={photoUrl(photos[0], token!, photos[0].media_type === 'video' ? 'thumbnail' : 'original')}
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
alt=""
/>
)}
{photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -301,12 +311,14 @@ export default function JourneyPublicPage() {
}`}
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
>
<img
src={photoUrl(p, token!, 'thumbnail')}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{!posterlessVideo(p) && (
<img
src={photoUrl(p, token!, 'thumbnail')}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
{p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -327,12 +339,16 @@ export default function JourneyPublicPage() {
className="min-w-0 flex-1 cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
>
<img
src={photoUrl(photos[0], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
{posterlessVideo(photos[0]) ? (
<ClipTile />
) : (
<img
src={photoUrl(photos[0], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
)}
</button>
<div className="flex min-w-0 flex-1 flex-col" style={{ gap: 2 }}>
<button
@@ -340,24 +356,32 @@ export default function JourneyPublicPage() {
className="min-h-0 flex-1 cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}
>
<img
src={photoUrl(photos[1], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[1].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
{posterlessVideo(photos[1]) ? (
<ClipTile />
) : (
<img
src={photoUrl(photos[1], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[1].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
)}
</button>
<button
type="button"
className="relative min-h-0 flex-1 cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}
>
<img
src={photoUrl(photos[2], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[2].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
{posterlessVideo(photos[2]) ? (
<ClipTile />
) : (
<img
src={photoUrl(photos[2], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[2].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
)}
{photos.length > 3 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<span className="flex items-center gap-1 text-[13px] font-semibold text-white">
@@ -529,15 +553,19 @@ export default function JourneyPublicPage() {
})
}
>
<img
src={photoUrl(photo, token!, 'thumbnail')}
className={`h-full w-full transition-transform hover:scale-105 ${photo.media_type === 'video'
? 'object-contain bg-black'
: 'object-cover'
}`}
alt=""
loading="lazy"
/>
{posterlessVideo(photo) ? (
<ClipTile />
) : (
<img
src={photoUrl(photo, token!, 'thumbnail')}
className={`h-full w-full transition-transform hover:scale-105 ${photo.media_type === 'video'
? 'object-contain bg-black'
: 'object-cover'
}`}
alt=""
loading="lazy"
/>
)}
{photo.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur">
@@ -825,7 +853,7 @@ export default function JourneyPublicPage() {
dark={document.documentElement.classList.contains('dark')}
readOnly
onEntryClick={(entry) => setViewingEntry(entry as any)}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/thumbnail`}
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
cartoApiKey={cartoApiKey}
showMood={journey.show_mood !== 0}
@@ -269,8 +269,11 @@ describe('JourneyPublicPage wiring', () => {
const { hook } = setup({ isMobile: true, desktopTwoColumn: false });
expect(screen.getByTestId('mobile-timeline')).toBeInTheDocument();
// The cover card draws this URL in an <img>, so it has to be the poster, not
// the file itself: /original is the clip for a video and a full-size JPEG for
// a photo (#2341). The own-journey cover already asks for /thumbnail.
const publicPhotoUrl = mocks.captured.mobileTimeline.publicPhotoUrl as (id: number) => string;
expect(publicPhotoUrl(55)).toBe('/api/public/journey/tok-1/photos/55/original');
expect(publicPhotoUrl(55)).toBe('/api/public/journey/tok-1/photos/55/thumbnail');
(mocks.captured.mobileTimeline.onEntryClick as (e: PublicEntry) => void)(buildEntry({ id: 9 }));
expect(hook.setViewingEntry).toHaveBeenCalledWith(expect.objectContaining({ id: 9 }));
+47 -8
View File
@@ -16,7 +16,9 @@ vi.mock('react-leaflet', () => ({
),
TileLayer: ({ url }: { url: string }) => <div data-testid="raster-tiles" data-url={url} />,
Marker: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
Polyline: () => <div data-testid="route-line" />,
Polyline: ({ positions }: { positions: [number, number][] }) => (
<div data-testid="route-line" data-positions={JSON.stringify(positions)} />
),
Tooltip: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
useMap: () => ({
fitBounds: vi.fn(),
@@ -60,6 +62,10 @@ function renderSharedTrip(token: string) {
);
}
// Leaflet is mocked, so every marker the last render drew is a recorded divIcon call.
const divIconMock = () => L.divIcon as unknown as ReturnType<typeof vi.fn>;
const iconHtml = () => divIconMock().mock.calls.map(c => String(c[0].html));
beforeEach(() => {
// SharedTripPage does NOT require authentication — do NOT seed auth store
resetAllStores();
@@ -818,10 +824,7 @@ describe('SharedTripPage', () => {
const orsay = { id: 202, name: 'Orsay', lat: 48.85, lng: 2.32, category: null };
const notre = { id: 203, name: 'Notre-Dame', lat: 48.853, lng: 2.35, category: null };
// Every divIcon call the last render produced, as raw html strings.
const iconHtml = () => (L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: any[]) => String(c[0].html));
beforeEach(() => (L.divIcon as unknown as ReturnType<typeof vi.fn>).mockClear());
beforeEach(() => divIconMock().mockClear());
it('numbers the stops by order_index, not by payload order', async () => {
await open('order-map-token', payload({
@@ -966,15 +969,15 @@ describe('SharedTripPage', () => {
// The payload nests the category on a day's assignments but sends it flat on the
// trip-wide pool, so reading only the nested shape painted every marker indigo.
it('reads the flat category_color the trip pool sends', async () => {
(L.divIcon as unknown as ReturnType<typeof vi.fn>).mockClear();
divIconMock().mockClear();
await open('flatcat-token', payload({
days: [],
places: [{ id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category_color: '#ff8800', category_icon: 'landmark' }],
assignments: {},
}));
await waitFor(() => expect((L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(0));
const html = (L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: any[]) => String(c[0].html));
await waitFor(() => expect(iconHtml().length).toBeGreaterThan(0));
const html = iconHtml();
expect(html.some((h: string) => h.includes('#ff8800'))).toBe(true);
expect(html.some((h: string) => h.includes('#6366f1'))).toBe(false);
});
@@ -1391,6 +1394,42 @@ describe('SharedTripPage', () => {
// The booked night is not: it is already on the day as its own chip.
expect(screen.queryByText('Unter den Linden 77')).toBeNull();
});
it('numbers the map and draws the day line over the traveller\'s stops only, and keeps the hotel as an unnumbered pin', async () => {
// Since the reseat the booked night heads its day, so counting it gave the hotel
// badge 1, every real stop one more than the app shows, and a line setting off
// from where the day ends.
divIconMock().mockClear();
const hotel = { ...place(602, 'Hotel Adlon', 'Unter den Linden 77'), lat: 52.52, lng: 13.38 };
const rodin = { ...place(601, 'Musee Rodin', '77 Rue de Varenne'), lat: 48.86, lng: 2.32 };
const orsay = { ...place(603, 'Musee d Orsay', '1 Rue de la Legion d Honneur'), lat: 48.86, lng: 2.33 };
await open('stay-map-token', payload({
days,
assignments: {
41: [
{ id: 402, day_id: 41, order_index: 0, notes: null, accommodation_id: 7, place: hotel },
{ id: 401, day_id: 41, order_index: 1, notes: null, accommodation_id: null, place: rodin },
{ id: 403, day_id: 41, order_index: 2, notes: null, accommodation_id: null, place: orsay },
],
},
accommodations: [{ id: 7, place_id: 602, start_day_id: 41, end_day_id: 41, place_name: 'Hotel Adlon' }],
}));
fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
await waitFor(() => expect(iconHtml().some((h: string) => h.includes('>1<'))).toBe(true));
// Rodin is 1 and Orsay is 2, as in the planner; nothing wears 3 and the hotel's
// pin is still drawn, without a badge.
const html = iconHtml();
expect(html.filter((h: string) => h.includes('>1<'))).toHaveLength(1);
expect(html.filter((h: string) => h.includes('>2<'))).toHaveLength(1);
expect(html.some((h: string) => h.includes('>3<'))).toBe(false);
expect(html.filter((h: string) => !h.includes('<span'))).toHaveLength(1);
// The day line joins the two stops and does not start at the hotel.
const line = screen.getByTestId('route-line');
expect(JSON.parse(line.getAttribute('data-positions') ?? '[]')).toEqual([[48.86, 2.32], [48.86, 2.33]]);
});
});
// ── #2320: the richer read-only detail ─────────────────────────────────
+22 -11
View File
@@ -167,22 +167,33 @@ export default function SharedTripPage() {
// trip-wide pool has none (it arrives by created_at). The index runs over the full
// sorted assignment list, like the planner does, so a stop without coordinates still
// consumes a number and the app and the share link agree on what "3" means.
//
// The stop a booked night wrote onto its check-in day heads that day since the
// reseat, and the planner's numbers leave it out (the day list below does too). So
// the numbers and the day line are counted over the traveller's own stops, or the
// hotel wore badge 1, every real stop read one higher than in the app, and the line
// set off from where the day ends. Its pin stays on the day, unnumbered, the way the
// planner keeps the hotel on the map.
const dayAssignments = selectedDay
? [...(assignments[String(selectedDay)] || [])].sort((a: any, b: any) => a.order_index - b.order_index)
: [];
const dayStops = dayAssignments.filter(a => a.accommodation_id == null);
const dayOrderMap: Record<number, number[]> = {};
dayAssignments.forEach((a: any, i: number) => {
dayStops.forEach((a, i) => {
if (!a.place?.id) return;
(dayOrderMap[a.place.id] ||= []).push(i + 1);
});
const dayPlaces: any[] = [];
const seenPlaceIds = new Set<number>();
for (const a of dayAssignments as any[]) {
const p = a.place;
if (!p?.lat || !p?.lng || seenPlaceIds.has(p.id)) continue;
seenPlaceIds.add(p.id);
dayPlaces.push(p);
}
// The places these assignments sit on, in their order, each drawn once.
const locatedPlaces = (list: typeof dayAssignments) => {
const seen = new Set<number>();
return list.map(a => a.place).filter(p => {
if (!p?.lat || !p?.lng || seen.has(p.id)) return false;
seen.add(p.id);
return true;
});
};
const dayPlaces = locatedPlaces(dayAssignments);
const dayLine = locatedPlaces(dayStops);
const mapPlaces = selectedDay ? dayPlaces : (places || []).filter((p: any) => p?.lat && p?.lng);
// Open framed on the trip's places instead of on Paris. MapContainer only reads center/zoom
@@ -451,9 +462,9 @@ export default function SharedTripPage() {
/>
)}
<FitBoundsToPlaces places={mapPlaces} framedOnMount={framed !== null} />
{selectedDay && mapPlaces.length > 1 && (
{selectedDay && dayLine.length > 1 && (
<Polyline
positions={mapPlaces.map((p: any) => [p.lat, p.lng])}
positions={dayLine.map(p => [p.lat, p.lng])}
// Dashed and straight on purpose: it shows the order of the day's stops,
// not the roads between them. A real route would mean sending the
// itinerary to a third party for every anonymous visitor of a shared
+87 -1
View File
@@ -3,7 +3,7 @@ import React from 'react';
import { render, screen, waitFor, act, fireEvent } from '../../tests/helpers/render';
import { Routes, Route } from 'react-router';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment } from '../../tests/helpers/factories';
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment, buildReservation } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore';
import { usePluginStore } from '../store/pluginStore';
@@ -173,6 +173,34 @@ vi.mock('../components/Trips/TripMembersModal', () => ({
},
}));
// The road-trip rail and the booking dialog it brings along (#2428). Both capture their
// props so a case can drive the rail's handlers and read what the page did with them.
type RoadtripSidebarStubProps = { onOpenBooking?: (reservationId: number) => void; canEditBookings?: boolean };
const capturedRoadtripSidebarProps: { current: RoadtripSidebarStubProps } = { current: {} };
vi.mock('../components/Roadtrip/RoadtripSidebar', () => ({
default: (props: RoadtripSidebarStubProps) => {
capturedRoadtripSidebarProps.current = props;
return React.createElement('div', { 'data-testid': 'roadtrip-sidebar' });
},
}));
type TransportDetailStubProps = { transportDetail?: { id: number } | null };
const capturedTransportDetailModalProps: { current: TransportDetailStubProps } = { current: {} };
vi.mock('../components/Planner/DayPlanSidebarTransportDetailModal', () => ({
DayPlanSidebarTransportDetailModal: (props: TransportDetailStubProps) => {
capturedTransportDetailModalProps.current = props;
return null;
},
}));
vi.mock('../components/Roadtrip/RoadtripCorridorPanel', () => ({
default: () => React.createElement('div', { 'data-testid': 'roadtrip-corridor-panel' }),
}));
vi.mock('../components/Roadtrip/RoadtripLimitsCard', () => ({
default: () => null,
}));
// Configurable usePlaceSelection mock — lets tests set a specific selected place
const mockPlaceSelectionState: { selectedPlaceId: number | null; selectedAssignmentId: number | null } = {
selectedPlaceId: null,
@@ -253,6 +281,8 @@ beforeEach(() => {
capturedTripMembersModalProps.current = {};
capturedFileManagerProps.current = {};
capturedPlaceInspectorProps.current = {};
capturedRoadtripSidebarProps.current = {};
capturedTransportDetailModalProps.current = {};
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
@@ -1709,4 +1739,60 @@ describe('TripPlannerPage', () => {
});
});
});
describe('FE-PAGE-PLANNER-052: Road trip mode opens a booking the way the day plan does (#2428)', () => {
// The rail is mounted only with the addon on and the mode on for this trip; the
// road-trip hooks then read their own endpoints, answered empty here.
const enterRoadtrip = () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({ addons: [{ id: 'roadtrip', type: 'roadtrip' }] })),
http.get('/api/trips/42/roadtrip/vias', () => HttpResponse.json({ vias: [], tracks: [] })),
http.get('/api/trips/42/roadtrip/preferences', () => HttpResponse.json({ tripId: 42, preferences: {} })),
);
sessionStorage.setItem('trip-roadtrip-42', '1');
};
it('a flight opens the transport detail view the rail brings along, a table opens its editor', async () => {
enterRoadtrip();
vi.useFakeTimers();
seedTripStore({ id: 42 });
const flight = buildReservation({ id: 70, trip_id: 42, type: 'flight', title: 'LH 2020' });
const table = buildReservation({ id: 11, trip_id: 42, type: 'restaurant', title: 'Tisch Bullerei' });
seedStore(useTripStore, { reservations: [flight, table] });
renderPlannerPage(42);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId('roadtrip-sidebar')).toBeInTheDocument();
});
expect(screen.queryByTestId('day-plan-sidebar')).not.toBeInTheDocument();
// Without this the rail cannot tell a chip that opens from one that does not, and
// every table and ticket becomes a button that no-ops.
expect(capturedRoadtripSidebarProps.current.canEditBookings).toBe(true);
// A terminal row, a ride pill and a map endpoint all set the booking to show, and
// under Days the day panel owns the dialog that shows it. Here it has to be the
// rail's own copy, or nothing shows.
act(() => { capturedRoadtripSidebarProps.current.onOpenBooking?.(70); });
await waitFor(() => {
expect(capturedTransportDetailModalProps.current.transportDetail).toMatchObject({ id: 70 });
});
expect(capturedReservationModalProps.current.isOpen).toBe(false);
act(() => { capturedRoadtripSidebarProps.current.onOpenBooking?.(11); });
await waitFor(() => {
expect(capturedReservationModalProps.current.isOpen).toBe(true);
});
expect(capturedReservationModalProps.current.reservation).toMatchObject({ id: 11 });
// A booking the trip does not hold opens nothing.
act(() => { capturedRoadtripSidebarProps.current.onOpenBooking?.(999); });
expect(capturedTransportDetailModalProps.current.transportDetail).toMatchObject({ id: 70 });
});
});
});
+1
View File
@@ -608,6 +608,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
if (TRANSPORT_TYPES.has(r.type)) setMapTransportDetail(r)
else openLinkedReservation?.(r)
}}
canEditBookings={can('reservation_edit', trip)}
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
@@ -449,6 +449,16 @@ describe('AdminSettingsTab', () => {
expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'true');
});
it('FE-ADMSET-030f: with a key but Amap or OpenStreetMap picked, the Google-only row says the switch is idle', () => {
// A key alone does not put Google behind the search; the server ignores the
// switch under another provider, and the row must not promise otherwise.
renderTab({ placesGoogleOnly: true, hasMapsKey: true, placesProvider: 'openstreetmap' });
const hint = toggleFor('Search with Google only').closest<HTMLElement>('.flex.items-center.justify-between')!.querySelectorAll('p')[1];
expect(hint.textContent).not.toMatch(/Every search and every suggestion goes to Google Places/);
expect(hint.textContent).not.toMatch(/Needs a Google Maps API key/);
expect(hint.textContent).toMatch(/provider/i);
});
it('FE-ADMSET-031: the API keys card leads with the TREK index and no weather panel', () => {
renderTab();
+5 -3
View File
@@ -7,6 +7,7 @@ import CustomSelect from '../../components/shared/CustomSelect'
import GoogleOptions from './GoogleOptions'
import ProviderBlock from './ProviderBlock'
import TrekApiCard from './TrekApiCard'
import { placesGoogleOnlyHint } from '../../utils/placeSource'
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
@@ -523,12 +524,13 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</div>
{/* The one row here that is about where a search goes rather than
what the key may be spent on. Without a key it is a promise the
search cannot keep, and the subtitle says so. */}
what the key may be spent on. Without a key, or with Amap or
OpenStreetMap holding the slot, it is a promise the search
cannot keep, and the subtitle says so. */}
<div className="flex items-center justify-between gap-4 py-3">
<div>
<p className="text-sm font-medium text-content-secondary">{t('admin.placesGoogleOnly.title')}</p>
<p className="text-xs text-content-faint mt-0.5">{t(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')}</p>
<p className="text-xs text-content-faint mt-0.5">{t(placesGoogleOnlyHint(hasMapsKey, placesProvider))}</p>
</div>
<ToggleSwitch
on={placesGoogleOnly}
+2 -2
View File
@@ -4,7 +4,7 @@ import { ChevronRight } from 'lucide-react'
interface GoogleOptionsProps {
/** "Google options" — the heading of the fold. */
title: string
/** Right-hand summary while folded, e.g. "3 of 4 on". */
/** Right-hand summary while folded, e.g. "4 of 5 on". */
summary: string
children: React.ReactNode
}
@@ -15,7 +15,7 @@ interface GoogleOptionsProps {
* Only these switches fold away. The key fields above stay visible: a field you
* have to go looking for is worse than a long page, and the keys are the reason
* an admin opens this card at all. The switches are the opposite — set once,
* then never touched, and there are four of them.
* then never touched, and there are five of them.
*
* A native <details> rather than a state hook: keyboard operable and announced
* to screen readers without writing any of that, and it survives a re-render
+33 -1
View File
@@ -1,4 +1,4 @@
// FE-ADMHOOK-001 to FE-ADMHOOK-049
// FE-ADMHOOK-001 to FE-ADMHOOK-051
import { http, HttpResponse } from 'msw';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -276,6 +276,38 @@ describe('useAdmin', () => {
expect(toastCalls.some(c => c.type === 'error')).toBe(true);
});
it('FE-ADMHOOK-050: handleTogglePlacesGoogleOnly flips the switch and PUTs it', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/admin/places-google-only', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ enabled: true });
})
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleTogglePlacesGoogleOnly();
});
expect(body).toEqual({ enabled: true });
expect(result.current.placesGoogleOnly).toBe(true);
});
it('FE-ADMHOOK-051: a rejected Google-only update rolls the switch back and toasts', async () => {
server.use(
http.put('/api/admin/places-google-only', () => HttpResponse.json({ error: 'locked' }, { status: 500 }))
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleTogglePlacesGoogleOnly();
});
expect(result.current.placesGoogleOnly).toBe(false);
expect(toastCalls).toContainEqual({ type: 'error', message: 'locked' });
});
it('FE-ADMHOOK-016: handleSaveWebauthn trims and re-reads the app config', async () => {
let body: Record<string, unknown> | null = null;
server.use(
+15 -5
View File
@@ -109,11 +109,6 @@ export function useAdmin() {
// reads the switch on the server, so nothing else in the client needs it.
const [placesGoogleOnly, setPlacesGoogleOnlyState] = useState<boolean>(false)
useEffect(() => { adminApi.getPlacesGoogleOnly().then(d => setPlacesGoogleOnlyState(d.enabled)).catch(() => {}) }, [])
const handleTogglePlacesGoogleOnly = async () => {
const next = !placesGoogleOnly
setPlacesGoogleOnlyState(next)
try { await adminApi.updatePlacesGoogleOnly(next) } catch { setPlacesGoogleOnlyState(!next) }
}
// Transit backend (#1699). googleKeySource says where a Google key would come
// from for this admin — null means picking Google changes nothing, since the
@@ -202,6 +197,9 @@ export function useAdmin() {
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled, logout } = useAuthStore()
// The store's copy is what the search screens gate the "search Google
// instead" line on, so a saved choice lands there the way a saved key does.
const setStorePlacesProvider = useAuthStore(s => s.setPlacesProvider)
const navigate = useNavigate()
const toast = useToast()
@@ -364,12 +362,24 @@ export function useAdmin() {
}
}
const handleTogglePlacesGoogleOnly = async () => {
const next = !placesGoogleOnly
setPlacesGoogleOnlyState(next)
try {
await adminApi.updatePlacesGoogleOnly(next)
} catch (err: unknown) {
setPlacesGoogleOnlyState(!next)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const handleSavePlacesProvider = async (value: string) => {
const previous = placesProvider
setPlacesProvider(value)
setSavingPlacesProvider(true)
try {
await authApi.updateAppSettings({ places_provider: value })
setStorePlacesProvider(value)
toast.success(t('admin.placesProvider.saved'))
} catch (err: unknown) {
setPlacesProvider(previous)
@@ -30,6 +30,9 @@ export interface PublicPhoto {
caption?: string | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
/** Null on a local clip whose poster frame could not be grabbed; the page
* draws a marked tile instead of an <img> then (#2341). */
thumbnail_path?: string | null
duration_ms?: number | null
}
@@ -44,6 +47,9 @@ export interface PublicGalleryPhoto {
caption?: string | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
/** Null on a local clip whose poster frame could not be grabbed; the gallery
* draws a marked tile instead of an <img> then (#2341). */
thumbnail_path?: string | null
duration_ms?: number | null
/** Where and when the picture was taken (#1614). Absent for most photos, and the
* coordinates are withheld entirely unless the owner shared the map. */
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import type { JourneyMapHandle } from '../../components/Journey/JourneyMap'
import { useIsMobile } from '../../hooks/useIsMobile'
import { DAY_COLORS } from '../../components/Journey/dayColors'
import { posterlessVideo } from '../journeyDetail/JourneyDetailPage.helpers'
import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublicModel'
/**
@@ -86,10 +87,14 @@ export function useJourneyPublic() {
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
// Through the share token, like every other picture on this page: the
// reader has no session, so `/api/photos/:id` would answer 401.
// reader has no session, so `/api/photos/:id` would answer 401. The
// poster rather than the file, as the owner's own map card does, since
// /original is the whole clip for a video; a clip that came up without
// a poster is dropped instead of holding a blank slot (#2341).
photoUrls: (e.photos ?? [])
.filter(p => !posterlessVideo(p))
.slice(0, 3)
.map((p: { photo_id: number }) => `/api/public/journey/${token}/photos/${p.photo_id}/original`),
.map(p => `/api/public/journey/${token}/photos/${p.photo_id}/thumbnail`),
}
})
}, [mapEntries, sortedDates, token])
@@ -1,5 +1,5 @@
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
// FE-TP-ROAD-001 to FE-TP-ROAD-108
// FE-TP-ROAD-001 to FE-TP-ROAD-112
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -2422,3 +2422,69 @@ describe('useTripPlanner road trip: a hit handed to the full form', () => {
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 6, 900, null)
})
})
describe('useTripPlanner road trip: a ride on the drive (#2428)', () => {
/**
* One card: Hamburg, the airport it flies from, the airport it lands at, Munich. The
* ride draws no line of its own, so the card's geometry jumps from one terminal to the
* other and a click under the flight path projects onto that jump. The terminals carry
* the index of the stop after them, as `seatCarrierStops` seats them: they stand in
* for no stored stop of their own.
*/
const terminal = (role: 'departure' | 'arrival') => ({
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
placeId: -70,
ownerIndex: 1,
lat: role === 'departure' ? 53.63 : 48.35,
lng: role === 'departure' ? 9.99 : 11.78,
carrier: { reservationId: 70, type: 'flight', role, title: 'LH 2020', code: role === 'departure' ? 'HAM' : 'MUC', at: null },
})
const flownDay = () => {
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
rt.corridor.day = { dayId: 5, dayNumber: 1 }
rt.routes.days = [{
dayId: 5,
dayNumber: 1,
stops: [{ assignmentId: 1, placeId: 10, lat: 53.55, lng: 9.99 }, terminal('departure'), terminal('arrival'), { assignmentId: 2, placeId: 20, ownerIndex: 1, lat: 48.13, lng: 11.58 }],
geometry: [[53.55, 9.99], [53.63, 9.99], [48.35, 11.78], [48.13, 11.58]],
}]
}
it('FE-TP-ROAD-109: a via is filed on the road into the departure terminal, never on the ride or the road out of the arrival', async () => {
flownDay()
const { result } = await renderRoadtrip()
await act(async () => { await result.current.addRoadtripVia(53.59, 9.99) })
expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.59, 9.99)
rt.vias.add.mockClear()
// Under the flight path, and on the road out of the airport it lands at: a via there
// would be stored at the next stop's position and bend that stop's road instead.
await act(async () => { await result.current.addRoadtripVia(50.99, 10.885) })
await act(async () => { await result.current.addRoadtripVia(48.24, 11.68) })
expect(rt.vias.add).not.toHaveBeenCalled()
})
it('FE-TP-ROAD-110: a via dragged onto the road out of the arrival moves without an anchor', async () => {
flownDay()
const { result } = await renderRoadtrip()
await act(async () => { await result.current.moveRoadtripVia(5, 3, 48.24, 11.68) })
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 48.24, 11.68, undefined)
})
it('FE-TP-ROAD-111: nothing is stopped at on a flight; behind the arrival the road goes on', async () => {
flownDay()
const { result } = await renderRoadtrip()
expect(result.current.manualStopTargetFor(50.99, 10.885)).toBeNull()
expect(result.current.manualStopTargetFor(48.24, 11.68)).toMatchObject({ dayId: 5 })
})
it('FE-TP-ROAD-112: the ride is drawn on the map as its booking, on top of whatever the reader switched on', async () => {
flownDay()
const { result } = await renderRoadtrip()
expect(result.current.roadtripConnections).toContain(70)
})
})
@@ -14,6 +14,7 @@ import { resolvePluginIcon } from '../../components/shared/PluginIcon'
import { useTranslation, translateApiError } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
import { getDayOrder } from '../../utils/dayOrder'
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import { isOvernightCategory } from '../../components/Roadtrip/stopKinds'
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
import type { BookingImportPreviewItem } from '@trek/shared'
@@ -215,7 +216,6 @@ export function useTripPlanner() {
}).catch(() => {})
}, [])
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
const tripPagePlugins = allPlugins.filter(p => p.type === 'trip-page')
const tripPluginIds = tripPagePlugins.map(p => p.id).join(',')
+7
View File
@@ -52,6 +52,10 @@ interface AuthState {
* into one "has a search key": which of the two is missing decides what the
* admin has to go and do. */
hasAmapKey: boolean
/** The admin's places provider choice, as app-config normalises it: 'auto',
* 'google', 'amap' or 'openstreetmap'. Read with hasMapsKey to tell whether
* a search can reach Google at all (utils/placeSource googleHoldsSlot). */
placesProvider: string
serverTimezone: string
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
@@ -81,6 +85,7 @@ interface AuthState {
setAppVersion: (val: string) => void
setHasMapsKey: (val: boolean) => void
setHasAmapKey: (val: boolean) => void
setPlacesProvider: (val: string) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
@@ -132,6 +137,7 @@ export const useAuthStore = create<AuthState>()(
appVersion: '',
hasMapsKey: false,
hasAmapKey: false,
placesProvider: 'auto',
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
@@ -402,6 +408,7 @@ export const useAuthStore = create<AuthState>()(
setAppVersion: (val: string) => set({ appVersion: val }),
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setHasAmapKey: (val: boolean) => set({ hasAmapKey: val }),
setPlacesProvider: (val: string) => set({ placesProvider: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
+20 -14
View File
@@ -194,6 +194,20 @@ async function _writeDayToDb(dayId: number, state: TripStoreState): Promise<void
// ── Zustand event reducer ─────────────────────────────────────────────────────
/**
* A day with `incoming` seated where the server put it, the rows around it renumbered
* the way the local move reducer renumbers them. A booked night lands at the front of
* its day and the rows behind it were moved up one there; appended with that index it
* would sit tied with the old first row and be drawn second until the next reload. An
* index past the end, or none at all, appends.
*/
function seatByOrder(day: Assignment[], incoming: Assignment): Assignment[] {
const ordered = day.slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
const at = Math.max(0, Math.min(incoming.order_index ?? ordered.length, ordered.length))
ordered.splice(at, 0, incoming)
return ordered.map((a, i) => (a.order_index === i ? a : { ...a, order_index: i }))
}
type StateApplier = (payload: Record<string, unknown>, state: TripStoreState) => Partial<TripStoreState>
/**
@@ -252,19 +266,8 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
}
// Genuinely new — including a legitimate second assignment of a place
// already on this day (no temp version to reconcile). Seated where the server
// put it: a booked night lands at the front of its day, and the rows behind it
// were moved up one there. Appending it with that index would leave it tied
// with the old first row and drawn second until the next reload.
const ordered = existing.slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
const at = Math.max(0, Math.min(incoming.order_index ?? ordered.length, ordered.length))
ordered.splice(at, 0, incoming)
return {
assignments: {
...state.assignments,
[dayKey]: ordered.map((a, i) => (a.order_index === i ? a : { ...a, order_index: i })),
}
}
// already on this day (no temp version to reconcile).
return { assignments: { ...state.assignments, [dayKey]: seatByOrder(existing, incoming) } }
},
'assignment:updated': (payload, state) => {
const dayKey = String((payload.assignment as Assignment).day_id)
@@ -290,11 +293,14 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
const oldKey = String(payload.oldDayId)
const newKey = String(payload.newDayId)
const movedAssignment = payload.assignment as Assignment
// The target day is read after the row has left it, so a night re-seated on its
// own day (oldKey === newKey) is taken out first and put back where it now sits.
const target = (state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id)
return {
assignments: {
...state.assignments,
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
[newKey]: seatByOrder(target, movedAssignment),
}
}
},
+26 -6
View File
@@ -233,12 +233,20 @@
}
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
.trek-dash .hero-title {
font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0;
font-size: 104px; font-weight: 600; line-height: 1.12; letter-spacing: -0.045em; margin: 0;
text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4);
/* Two lines at most, a word longer than the card broken inside it: the rows above
and below have a fixed budget and a third line would run into the badge row. */
overflow-wrap: anywhere;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
/* The clamp clips at the line boxes and Poppins paints past them (ascender 1.05em,
descender 0.35em): at 0.9 every y, g and p on the last line lost its tail and
É and Å on the first their accent. The padding makes room for the ink and the
margin takes it back, so the title sits where it did. At 1.12 a clipped third
line keeps its plain ascenders under that padding; an accented capital on it
still leaves a sliver, as it does on dev today, and only a much looser title
would hide that. */
padding-block: .1em; margin-block: -.1em;
}
/* ----------------- boarding pass ----------------- */
@@ -427,6 +435,11 @@
.trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; }
.trek-dash .trips.list-view .trip-card { display: grid; grid-template-columns: 520px 1fr; gap: 0; height: auto; }
.trek-dash .trips.list-view .trip-cover { border-radius: var(--r-lg) 0 0 var(--r-lg); height: 100px; aspect-ratio: unset; }
/* The row's cover is 100px and the title hangs from its bottom edge, so every
line it gains grows upwards: at 26px a second one starts above the status
badge at top 16px and runs through the pill. The row has 484px of text to
itself, one line is the whole title either way. */
.trek-dash .trips.list-view .trip-name { -webkit-line-clamp: 1; }
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-end; padding: 16px 36px; gap: 28px; }
/* Date rendered as a peer of the counts, set off by a vertical divider rather than
floating alone at the far left. */
@@ -481,7 +494,15 @@
.trek-dash .trip-action-btn:hover { background: oklch(0.22 0.01 260 / .82); transform: scale(1.06); }
.trek-dash .trip-action-btn svg { width: 17px; height: 17px; }
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
.trek-dash .trip-name {
font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.12; margin: 0;
text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38);
/* The cover is a fixed width and clipped, so the same clamp and ink room as
.hero-title: one long word used to run off the card edge as a single line. */
overflow-wrap: anywhere;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
padding-block: .1em; margin-block: -.1em;
}
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
.trek-dash .trip-body { padding: 18px 20px 20px; }
@@ -730,10 +751,9 @@
.trek-dash .trips.list-view .trip-card { grid-template-columns: 1fr; min-height: 0; }
.trek-dash .trips.list-view .trip-cover { height: 110px; aspect-ratio: unset; border-radius: 0; }
.trek-dash .trips.list-view .trip-cover-content { left: 16px; right: 16px; bottom: 11px; }
.trek-dash .trips.list-view .trip-name {
font-size: 18px; overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
/* Taller banner (110px), smaller type and the title 11px off the bottom: two
lines end below the badge here, so the phone row keeps them. */
.trek-dash .trips.list-view .trip-name { font-size: 18px; -webkit-line-clamp: 2; }
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: center; padding: 10px 16px; }
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: center; font-size: 12.5px; }
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 12.5px; }
+87 -3
View File
@@ -6,10 +6,26 @@ import { describe, it, expect } from 'vitest'
// releaseNoticeCss.test.ts pins the notice's stacking.
describe('dashboard css', () => {
const css = readFileSync(resolve(process.cwd(), 'src/styles/dashboard.css'), 'utf8')
const block = (selector: string): string => {
const at = css.indexOf(selector)
const within = (source: string, selector: string): string => {
const at = source.indexOf(selector)
expect(at, `${selector} missing from dashboard.css`).toBeGreaterThan(-1)
return css.slice(at, css.indexOf('}', at) + 1)
return source.slice(at, source.indexOf('}', at) + 1)
}
const block = (selector: string): string => within(css, selector)
const phone = (): string => {
const at = css.indexOf('@media (max-width: 720px)')
expect(at, 'the phone block').toBeGreaterThan(-1)
return css.slice(at, css.indexOf('\n}', at))
}
// The leading number of a declaration, undefined when the rule does not set it.
const number = (rule: string, prop: string): number | undefined => {
const found = new RegExp(`(?:^|[;{\\n])\\s*${prop}:\\s*(-?[\\d.]+)`).exec(rule)
return found ? Number(found[1]) : undefined
}
const value = (rule: string, prop: string): number => {
const found = number(rule, prop)
expect(found, `${prop} missing from ${rule.slice(0, 48)}`).toBeDefined()
return found!
}
it('FE-DASH-CSS-001: a hero title that is one long word cannot widen the card', () => {
@@ -24,4 +40,72 @@ describe('dashboard css', () => {
expect(title).toMatch(/-webkit-line-clamp:\s*2/)
expect(title).toMatch(/overflow:\s*hidden/)
})
it('FE-DASH-CSS-002: the clamp clips at the line boxes, so they leave room for the ink', () => {
// Poppins paints past its line box (ascender 1.05em, descender 0.35em). With
// the clamp's overflow: hidden at line-height 0.9, every y, g and p on the
// last line lost its tail and É and Å on the first their accent. The room is
// padding the margin takes back, and the line-height has to keep a clipped
// third line's plain ascenders below that padding, which 1.12 just does (an
// accented capital on that third line still leaves a sliver, as at 0.9).
for (const selector of ['.trek-dash .hero-title {', '.trek-dash .trip-name {']) {
const rule = block(selector)
expect(rule, selector).toMatch(/overflow:\s*hidden/)
expect(value(rule, 'line-height'), selector).toBeGreaterThanOrEqual(1.12)
const room = /padding-block:\s*0?\.(\d+)em/.exec(rule)?.[1]
expect(room, `${selector} pads no room for the ink`).toBeDefined()
expect(rule, selector).toMatch(new RegExp(`margin-block:\\s*-0?\\.${room}em`))
}
})
it('FE-DASH-CSS-003: a grid or list card breaks a one-word title inside the cover and stops at two lines', () => {
// The same title that stretched the hero was cut at the cover edge on every
// card: the cover content is absolutely positioned and the cover clips.
const name = block('.trek-dash .trip-name {')
expect(name).toMatch(/overflow-wrap:\s*anywhere/)
expect(name).toMatch(/-webkit-line-clamp:\s*2/)
})
it('FE-DASH-CSS-004: a list row uses every title line that fits above the status badge', () => {
// The list cover is a fixed band: the badge is pinned near its top and the
// title hangs from its bottom edge, so each line the title gains grows upwards
// into the badge. At 26px over a 100px cover the two-line clamp put the first
// line at y 23 and ran it through the pill; the phone banner is taller and its
// type smaller, so there two fit. jsdom lays nothing out, so the row is
// measured from the rules the browser would apply.
const inherited = 1.5 // html's line-height; nothing in dashboard.css resets it
const badge = block('.trek-dash .trip-status {')
const badgeBottom =
value(badge, 'top') + 2 * value(badge, 'border') + 2 * value(badge, 'padding') + value(badge, 'font-size') * inherited
const name = block('.trek-dash .trip-name {')
// What the row ends up with: its own override where it has one, the shared
// .trip-name rule otherwise.
const shell = (rule: string, prop: string): number => number(rule, prop) ?? value(name, prop)
const rows = [
{
label: 'desktop',
cover: block('.trek-dash .trips.list-view .trip-cover {'),
content: block('.trek-dash .trip-cover-content {'),
row: block('.trek-dash .trips.list-view .trip-name {')
},
{
label: 'phone',
cover: within(phone(), '.trek-dash .trips.list-view .trip-cover {'),
content: within(phone(), '.trek-dash .trips.list-view .trip-cover-content {'),
row: within(phone(), '.trek-dash .trips.list-view .trip-name {')
}
]
for (const { label, cover, content, row } of rows) {
const fontSize = shell(row, 'font-size')
const line = fontSize * shell(row, 'line-height')
// The clamp pads .1em for the ink and takes it back with a negative margin,
// so the box keeps its height but paints that much higher.
const ceiling = value(cover, 'height') - value(content, 'bottom') - fontSize * shell(row, 'padding-block')
const clamp = shell(row, '-webkit-line-clamp')
expect(ceiling - clamp * line, `${label} list title over the badge`).toBeGreaterThanOrEqual(badgeBottom)
expect(clamp, `${label} list title leaves a line unused`).toBe(Math.floor((ceiling - badgeBottom) / line))
}
})
})
+33 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { offersGoogleRetry, sourceLabelFor } from './placeSource'
import { googleHoldsSlot, offersGoogleRetry, placesGoogleOnlyHint, selectGoogleHoldsSlot, sourceLabelFor } from './placeSource'
describe('offersGoogleRetry', () => {
it('FE-PLACESOURCE-001: offers Google for a list the index or OpenStreetMap answered, only where a key exists', () => {
it('FE-PLACESOURCE-001: offers Google for a list the index or OpenStreetMap answered, only where Google answers', () => {
expect(offersGoogleRetry('trek-places+openstreetmap', true)).toBe(true)
expect(offersGoogleRetry('openstreetmap', true)).toBe(true)
expect(offersGoogleRetry('trek-places+plugin', true)).toBe(true)
@@ -17,6 +17,37 @@ describe('offersGoogleRetry', () => {
})
})
describe('googleHoldsSlot', () => {
it('FE-PLACESOURCE-004: a key holds the slot under auto and google, not under Amap or OpenStreetMap', () => {
expect(googleHoldsSlot(true, 'auto')).toBe(true)
expect(googleHoldsSlot(true, 'google')).toBe(true)
// A stored key still serves photos and details here, but the server never
// walks the Google chain for a search, so a link that offered it would be dead.
expect(googleHoldsSlot(true, 'amap')).toBe(false)
expect(googleHoldsSlot(true, 'openstreetmap')).toBe(false)
expect(googleHoldsSlot(false, 'google')).toBe(false)
// Unrecognised reads as auto, as the server reads it.
expect(googleHoldsSlot(true, '')).toBe(true)
})
it('FE-PLACESOURCE-005: the store selector asks the same question of the auth state', () => {
expect(selectGoogleHoldsSlot({ hasMapsKey: true, placesProvider: 'auto' })).toBe(true)
expect(selectGoogleHoldsSlot({ hasMapsKey: true, placesProvider: 'openstreetmap' })).toBe(false)
expect(selectGoogleHoldsSlot({ hasMapsKey: false, placesProvider: 'auto' })).toBe(false)
})
})
describe('placesGoogleOnlyHint', () => {
it('FE-PLACESOURCE-006: names what the admin has to do before the switch does anything', () => {
expect(placesGoogleOnlyHint(false, 'auto')).toBe('admin.placesGoogleOnly.missingKey')
expect(placesGoogleOnlyHint(false, 'openstreetmap')).toBe('admin.placesGoogleOnly.missingKey')
expect(placesGoogleOnlyHint(true, 'amap')).toBe('admin.placesGoogleOnly.otherProvider')
expect(placesGoogleOnlyHint(true, 'openstreetmap')).toBe('admin.placesGoogleOnly.otherProvider')
expect(placesGoogleOnlyHint(true, 'auto')).toBe('admin.placesGoogleOnly.subtitle')
expect(placesGoogleOnlyHint(true, 'google')).toBe('admin.placesGoogleOnly.subtitle')
})
})
describe('sourceLabelFor', () => {
it('FE-PLACESOURCE-003: a row names its own index, else the list it came from', () => {
const t = (k: string) => k
+46 -15
View File
@@ -32,6 +32,52 @@ const SOURCE_KEYS: Record<string, string> = {
amap: 'places.source.amap',
}
/**
* Whether a search can reach Google on this install at all.
*
* A stored key is not enough. The server walks the Google key chain only while
* the admin's provider choice leaves Google the keyed slot, which is `auto` or
* `google`; under Amap or OpenStreetMap the key still serves photos and
* details, but a search sent to Google is answered by the index as before.
* Anything unrecognised reads as `auto`, the way the server reads it.
*/
export function googleHoldsSlot(hasMapsKey: boolean, placesProvider: string): boolean {
return hasMapsKey && placesProvider !== 'amap' && placesProvider !== 'openstreetmap'
}
/** The same question asked of the auth store, for `useAuthStore(selectGoogleHoldsSlot)`. */
export const selectGoogleHoldsSlot = (s: { hasMapsKey: boolean; placesProvider: string }): boolean =>
googleHoldsSlot(s.hasMapsKey, s.placesProvider)
/**
* Whether a result list can be sent to Google instead.
*
* The index and OpenStreetMap answer first, and Google is only asked when they
* find nothing, so a list that has the wrong place on it never reaches Google
* on its own. The link that sends it there is offered when Google holds the
* keyed slot (googleHoldsSlot above) and this list did not already come from
* Google: a list Google produced, alone or as the empty-case fallback, has
* nowhere further to go, and the offline cache is not a search at all.
*/
export function offersGoogleRetry(listSource: string, googleAnswers: boolean): boolean {
if (!googleAnswers || !listSource) return false
return !listSource.split('+').includes('google') && listSource !== 'offline-cache'
}
/**
* The line under the admin's "Search with Google only" switch.
*
* Two ways the switch can be a promise the search cannot keep, and they ask
* different things of the admin: paste a key, or hand the keyed slot back to
* Google. The subtitle that says what the switch does is only true of the
* third state.
*/
export function placesGoogleOnlyHint(hasMapsKey: boolean, placesProvider: string): string {
if (!hasMapsKey) return 'admin.placesGoogleOnly.missingKey'
if (!googleHoldsSlot(hasMapsKey, placesProvider)) return 'admin.placesGoogleOnly.otherProvider'
return 'admin.placesGoogleOnly.subtitle'
}
/**
* The label for one row.
*
@@ -40,21 +86,6 @@ const SOURCE_KEYS: Record<string, string> = {
* answered the call: Google never marks its places, and a merged list marks only
* the index side, so an unmarked row in one is OpenStreetMap by elimination.
*/
/**
* Whether a result list can be sent to Google instead.
*
* The index and OpenStreetMap answer first, and Google is only asked when they
* find nothing, so a list that has the wrong place on it never reaches Google
* on its own. The link that sends it there is offered when the instance has a
* Google key and this list did not already come from Google: a list Google
* produced, alone or as the empty-case fallback, has nowhere further to go, and
* the offline cache is not a search at all.
*/
export function offersGoogleRetry(listSource: string, hasMapsKey: boolean): boolean {
if (!hasMapsKey || !listSource) return false
return !listSource.split('+').includes('google') && listSource !== 'offline-cache'
}
export function sourceLabelFor(place: unknown, listSource: string, t: TranslationFn): string | null {
const own = (place as { source?: unknown } | null)?.source
const source = typeof own === 'string' && own