Compare commits

..

5 Commits

Author SHA1 Message Date
Maurice e79d0996ae Merge branches 'fix/mcp-trip-default-currency' and 'fix/mobile-reservation-detail-slot' into test/dev1-mcp-mobile 2026-09-22 09:59:05 +02:00
Maurice 8936049a81 fix(client): take the reported version as one anchored match before storing it
The dev branch gate on SonarCloud has been red since the 4.3.0 cycle on this
one line: the version from /api/config is written to localStorage, and the
taint engine did not read the replace() plus equality check as sanitisation
(S8475). The value is the match of ^[\w.+-]{1,64}$ now, the same characters
and length as before, in the shape the engine recognises.
2026-09-22 09:56:13 +02:00
Maurice c0b691b8e5 fix(mobile): reservation-detail widgets mount on the phone's booking cards
A plugin declaring the reservation-detail slot mounted at the foot of every
card in the desktop ReservationsPanel and nowhere else: the phone's transport
and booking cards never rendered the slot, so on a phone in portrait the
reservation was there and the widget was not, and turning the phone sideways
made it appear. ReservationPluginSlots mounts one PluginFrame per widget under
each card, scoped to that reservation with surface detail-slot, outside the
card's body button and gone in compact mode.

Closes #2440
2026-09-22 09:56:13 +02:00
Maurice aa64572bf0 fix(mcp): refuse an argument a tool never declared instead of dropping it
The SDK wraps a raw shape in a plain z.object, which strips an undeclared
key and runs the handler as if nothing was wrong: an assistant that wrote
stopType for stop_type was told the place was updated while nothing had
changed. The registry registers every shape as z.strictObject, so the call
answers "Unrecognized key" and tools/list advertises additionalProperties
false; the nested objects authored inline in the tool shapes are strict
for the same reason. A whole Zod schema, the way a plugin's manifest-built
tool arrives, passes through as its author wrote it.
2026-09-22 09:55:45 +02:00
Maurice 23aec0d898 fix(trips): a trip created without a currency takes the display currency
The service wrote `data.currency || 'EUR'`, and only the trip dialog ever
sends a currency, pre-filled from the display currency in Settings. MCP
create_trip and the plugin RPC send none, so every trip an assistant created
was a EUR trip whatever the person reads amounts in. The service now asks
SettingsService for the owner's display currency, admin default merged in,
and falls back to EUR only when that is left on "Trip currency". The REST
route inherits the fallback; the web client always sends the field, so it
sees no change.
2026-09-22 09:55:33 +02:00
192 changed files with 922 additions and 4075 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.3.1
version: 4.3.0
description: Minimal Helm chart for TREK app
appVersion: "4.3.1"
appVersion: "4.3.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.3.1",
"version": "4.3.0",
"private": true,
"type": "module",
"scripts": {
+2 -5
View File
@@ -284,7 +284,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, 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; 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> }) => {
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> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
@@ -359,9 +359,6 @@ 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 unless Google holds the keyed slot: without a Google key, or with
* Amap or OpenStreetMap picked as the provider, the index answers as usual.
* instead" link under a list the index answered with the wrong place. The
* server ignores it on an instance without a Google key.
*/
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-013
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -10,9 +10,7 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(),
}));
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { 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';
@@ -162,65 +160,6 @@ 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' } });
+2 -16
View File
@@ -6,7 +6,6 @@ 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'
@@ -112,7 +111,6 @@ 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.
@@ -232,9 +230,7 @@ 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')}>
{/* 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} />
<LinkIcon 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>
@@ -248,7 +244,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={() => setPendingDeleteId(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<button type="button" className="collab-link-chip__action" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<Trash2 size={13} aria-hidden="true" />
</button>
</span>
@@ -262,16 +258,6 @@ 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-059
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-040
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -1045,30 +1045,4 @@ 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, type SyntheticEvent } from 'react'
import { useEffect, useMemo, useState, useRef } 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,27 +21,6 @@ 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
@@ -455,11 +434,7 @@ 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%' }}
>
{posterlessVideo(gp) ? (
<ClipTile />
) : (
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={thumbnailFallback(gp)} />
)}
<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 }} />
</button>
))}
{availableGalleryPhotos.length === 0 && (
@@ -553,9 +528,13 @@ 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) ? (
<ClipTile />
// 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>
) : (
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={thumbnailFallback(p)} />
<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 }} />
)}
{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-010
// FE-JRN-COVER-001 to FE-JRN-COVER-008
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
@@ -112,23 +112,4 @@ 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,7 +1,6 @@
import { MapPin, Play, Plus } from 'lucide-react'
import { MapPin, 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'
@@ -26,13 +25,6 @@ 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
@@ -47,7 +39,7 @@ export interface CoverEntry {
entry_time?: string | null
mood?: string | null
weather?: string | null
photos?: CoverPhoto[]
photos?: CardPhoto[]
}
interface Props {
@@ -77,12 +69,8 @@ export default function JourneyEntryCover({
const { t, locale } = useTranslation()
const isSuggestion = entry.type === 'skeleton'
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 photoId = cardPhotoId(entry.photos?.[0])
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
const title = cardTitle(entry, t)
const place = cardPlace(entry)
@@ -124,9 +112,7 @@ 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">
{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" />}
<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-020
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -234,25 +234,4 @@ 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,7 +7,6 @@ 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
@@ -106,18 +105,12 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`}
onClick={() => onPhotoClick(photos, 0)}
>
{/* 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'
}`}
/>
)}
<img
src={photoUrl(photos[0], '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">
@@ -147,14 +140,12 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`}
onClick={() => onPhotoClick(photos, i)}
>
{!posterlessVideo(p) && (
<img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
<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,18 +3,10 @@ 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', () => ({}))
@@ -39,7 +31,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 out of the document by then.
// same, and the element is unmounted in the same tick.
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
const [, options] = plyrCtor.mock.calls[0]
@@ -53,19 +45,4 @@ 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')
})
})
+14 -20
View File
@@ -7,30 +7,24 @@ 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(props: Props): React.ReactElement {
return <PlyrVideo key={props.src} {...props} />
}
function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactElement {
export default function VideoPlayer({
src,
poster,
autoPlay = true,
style,
}: {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}): React.ReactElement {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
@@ -54,8 +48,8 @@ function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactE
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 out of the document
// by then, so the error it would otherwise report has nowhere to show.
// 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.
blankVideo: '',
})
@@ -66,7 +60,7 @@ function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactE
/* already torn down */
}
}
}, [autoPlay])
}, [src, autoPlay])
return (
<div
@@ -1030,26 +1030,6 @@ describe('MapView live location', () => {
expect(geoMock.cycleMode).toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-064b: turning the phone sideways and back takes the button with it', () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
geoMock.mode = 'off'
render(<MapView />)
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
// landscape crosses the 768px cut without remounting the map
act(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 844 })
window.dispatchEvent(new Event('resize'))
})
expect(screen.queryByRole('button', { name: 'Show my location' })).toBeNull()
act(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
window.dispatchEvent(new Event('resize'))
})
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
})
it('FE-COMP-MAPVIEW-077: the map draws no credit control of its own, on either width', () => {
const desktop = render(<MapView />)
// The desktop credit is Leaflet's own, in its own container; this component adds none.
+1 -5
View File
@@ -547,7 +547,6 @@ import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } f
import { useAuthStore } from '../../store/authStore'
import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
// Live-location rendering inside the Leaflet map. Subscribes via the
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
@@ -1034,10 +1033,7 @@ export const MapView = memo(function MapView({
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
// The width is followed rather than read once: a phone turned sideways and
// back crosses the breakpoint twice, and the button used to stay with
// whatever the map saw when it mounted.
const isMobile = useIsPhone()
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
+3 -7
View File
@@ -22,7 +22,6 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
@@ -1160,12 +1159,6 @@ export function MapViewGL({
const onMapReadyRef = useRef(onMapReady)
onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy), so
// the location button would be misleading; the phone, where real GPS lives,
// keeps it. Read here with the other hooks rather than beside the button:
// the tokenless branch below returns early, and a hook after it runs on some
// renders and not others.
const isMobile = useIsPhone()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
onClickRefs.current.map = onMapClick
@@ -2389,6 +2382,9 @@ export function MapViewGL({
)
}
// Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
+1 -34
View File
@@ -31,12 +31,6 @@ 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(() => {
@@ -263,7 +257,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, price: '120' } as any
const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null } 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({
@@ -275,33 +269,6 @@ 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 () => {
+10 -31
View File
@@ -16,7 +16,6 @@ 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
@@ -200,34 +199,11 @@ 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
}
/**
* 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)
// `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) {
const breaksPerDay = pageBreakPerDay()
const loc = _locale || undefined
const tr = _t || (k => k)
@@ -359,11 +335,14 @@ export async function downloadTripPDF({ trip, days, places, assignments: stored
}
// Build day HTML
const daysHtml = sorted.map((day, di) => {
// 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).
// 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).
const assigned = (assignments[String(day.id)] || [])
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.filter((a: any) => a.accommodation_id == null)
.sort((a: any, b: any) => (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)
@@ -101,15 +101,14 @@ vi.mock('../shared/Toast', () => ({
// ── Permissions mock ────────────────────────────────────────────────────────
// Flipped per test to render the read-only variants of the day rows; `denied`
// takes single actions away from an otherwise editing member.
const mockPermissions = vi.hoisted(() => ({ canEdit: true, denied: new Set<string>() }))
// Flipped per test to render the read-only variants of the day rows.
const mockPermissions = vi.hoisted(() => ({ canEdit: true }))
vi.mock('../../store/permissionsStore', async (importOriginal) => {
const actual = await importOriginal() as any
return {
...actual,
useCanDo: () => (action: string) => mockPermissions.canEdit && !mockPermissions.denied.has(action),
useCanDo: () => () => mockPermissions.canEdit,
}
})
@@ -183,7 +182,6 @@ beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
mockPermissions.canEdit = true
mockPermissions.denied.clear()
// clearAllMocks keeps implementations, so tests that swap the router out would
// otherwise leak into the ones after them.
vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({
@@ -940,18 +938,6 @@ describe('DayPlanSidebar', () => {
expect(onEditPlace).toHaveBeenCalledWith(place, assignment.id)
})
it('FE-PLANNER-DAYPLAN-214: without place_edit the menu still removes from the day but neither edits nor deletes the place (#2446)', () => {
mockPermissions.denied.add('place_edit')
const place = buildPlace({ id: 42, name: 'Louvre Museum' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />)
fireEvent.contextMenu(screen.getByText('Louvre Museum'))
expect(screen.getByText(/Remove from day/i)).toBeInTheDocument()
expect(screen.queryByText('Edit')).not.toBeInTheDocument()
expect(screen.queryByText('Delete')).not.toBeInTheDocument()
})
// ── Arrow reorder buttons ────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-041: arrow down button reorders day assignments', async () => {
@@ -188,9 +188,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canEditDays = can('day_edit', trip)
// Editing or deleting the place itself is a place right; taking it off the
// day stays a day right (#2446).
const canEditPlaces = can('place_edit', trip)
// The calendar subscription hands out a link that reads the trip without an
// account, so it sits behind the same permission as the public share link.
const canManageShare = can('share_manage', trip)
@@ -1151,7 +1148,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
tripActions,
can,
canEditDays,
canEditPlaces,
canManageShare,
noteUi,
setNoteUi,
@@ -1352,7 +1348,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
tripActions,
can,
canEditDays,
canEditPlaces,
canManageShare,
noteUi,
setNoteUi,
@@ -2102,13 +2097,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// does not exist here.
const navTargets = getNavigationTargets(place)
ctxMenu.open(e, [
canEditPlaces && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true },
canEditPlaces && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])
}}
onMouseEnter={e => {
@@ -233,81 +233,6 @@ 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, selectGoogleHoldsSlot, sourceLabelFor } from '../../utils/placeSource'
import { offersGoogleRetry, sourceLabelFor } from '../../utils/placeSource'
import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -196,8 +196,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const placesSessionRef = useRef(new PlacesSession())
const toast = useToast()
const { t, language, locale } = useTranslation()
const { placesEnrichEnabled } = useAuthStore()
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
const can = useCanDo()
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
const tripObj = useTripStore((s) => s.trip)
@@ -398,15 +397,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
const handleMapsSearch = async (provider?: 'google') => {
// 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
if (!mapsSearch.trim()) return
setIsSearchingMaps(true)
try {
// A pasted Google Maps or Amap link resolves server-side into a place
if (!provider && isMapUrl(trimmed)) {
const trimmed = mapsSearch.trim()
if (isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
setForm(prev => ({
@@ -423,8 +419,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
return
}
}
const result = await mapsApi.search(trimmed, language, locationBiasPoint, provider)
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint, provider)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
setMapsResults(result.places || [])
setSearchSource(result.source || '')
} catch (err: unknown) {
@@ -764,7 +760,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
language,
locale,
timeFormat,
googleAnswers,
hasMapsKey,
placesEnrichEnabled,
can,
tripObj,
@@ -842,7 +838,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
toast,
t,
language,
googleAnswers,
hasMapsKey,
placesEnrichEnabled,
can,
tripObj,
@@ -1009,9 +1005,8 @@ 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
where Google holds the key slot and for a list Google did not
already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
that has a key and for a list Google did not already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && (
<button
type="button"
onClick={() => handleMapsSearch('google')}
@@ -8,7 +8,6 @@ import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { usePluginStore } from '../../store/pluginStore';
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import type { AssignmentsMap } from '../../types';
@@ -1479,25 +1478,6 @@ describe('PlaceInspector', () => {
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press');
});
it('FE-PLANNER-INSPECTOR-102: a member without place_edit gets neither Edit nor Delete nor the inline rename (#2446)', () => {
// buildUser() is a plain member and buildTrip({ id: 1 }) belongs to somebody else.
usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } });
const onEdit = vi.fn();
const onDelete = vi.fn();
const onUpdatePlace = vi.fn();
const member = render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
fireEvent.doubleClick(screen.getByText(place.name));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
member.unmount();
// the same trip owned by this member shows them again
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: useAuthStore.getState().user!.id }) });
render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
});
it('FE-PLANNER-INSPECTOR-017b: a stop a booked night wrote is neither removed nor doubled here', () => {
// Taking it off the day would leave the booking behind with nothing on the drive and
@@ -32,7 +32,6 @@ import type { Place, Category, Day, Assignment, Reservation, TripFile, Assignmen
import type { CollectionStatus } from '@trek/shared'
import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
@@ -185,21 +184,11 @@ interface PlaceInspectorProps {
export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [], onEditTransport, onEditReservation,
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onRate,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate,
leftWidth = 0, rightWidth = 0,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) {
// Editing the place is a place right. The planner hands the handlers over
// regardless, and a member without the right saw Edit, Delete and the inline
// rename and got the server's 403 for each (#2446). Collection mode gates
// its own actions.
const can = useCanDo()
const trip = useTripStore(s => s.trip)
const mayEditPlace = mode !== 'trip' || can('place_edit', trip)
const onEdit = mayEditPlace ? editPlace : undefined
const onDelete = mayEditPlace ? deletePlace : undefined
const onUpdatePlace = mayEditPlace ? updatePlace : undefined
// Plugins that declared a place-detail slot mount at the bottom of this panel,
// scoped to the open place (trip mode only). Inline-filter like the other sites.
const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail')
@@ -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 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. */}
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. */}
{dayScoped && (
<div
className="border border-edge-faint bg-surface-tertiary text-content-secondary"
@@ -96,8 +96,7 @@ 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, showServiceStops: true,
dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
})
})
@@ -6,7 +6,6 @@ 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
@@ -62,9 +61,6 @@ 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
@@ -91,7 +87,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, showServiceStops })
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit })
onClose()
} catch (e) {
console.error('PDF error:', e)
@@ -80,31 +80,6 @@ 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
@@ -610,16 +585,11 @@ 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, 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()
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] })] })} />)
// 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()
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
@@ -736,6 +706,31 @@ 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,
@@ -1184,54 +1179,6 @@ 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)', () => {
@@ -1247,9 +1194,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
const rentalDay = () => {
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 stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
return day({
const rental = day({
stops,
legs: [leg(), leg(), leg()],
schedule: {
@@ -1262,11 +1209,8 @@ 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: [rentalDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
wrap(<RoadtripSidebar routes={routes({ days: [rental], 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()
@@ -1279,15 +1223,6 @@ 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 = [
@@ -1298,7 +1233,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} canEditBookings />)
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
expect(screen.getByText('19:30')).toBeInTheDocument()
expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
@@ -1312,21 +1247,4 @@ 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,7 +23,6 @@ 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'
@@ -32,7 +31,7 @@ import { FS } from './typeScale'
import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop'
import { bookingOpens, carrierIcon, rideText, terminalLine } from './carrierRide'
import { carrierIcon, rideText, terminalLine } from './carrierRide'
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
import type { Reservation } from '../../types'
import type { StayDraft } from './RoadtripStayModal'
@@ -116,12 +115,6 @@ 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> = {
@@ -690,10 +683,8 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
)
}
function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: {
function DriveBand({ leg, 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
@@ -701,14 +692,10 @@ function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const mode = leg?.mode ?? 'driving'
// 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
const Icon = 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 (!ride && isHop(leg)) {
if (isHop(leg)) {
return (
<div className="grid" style={RAIL_GRID}>
<span className="relative z-[1] flex min-h-[10px] flex-col items-center" aria-hidden>
@@ -724,14 +711,12 @@ function DriveBand({ leg, carrier, 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.pending')
: ride
? rideText(carrier, leg)
: t('roadtrip.leg.driveText', {
distance: formatDistance(leg.distance / 1000, distanceUnit),
time: formatDurationShort(leg.duration),
})}
{leg
? t('roadtrip.leg.driveText', {
distance: formatDistance(leg.distance / 1000, distanceUnit),
time: formatDurationShort(leg.duration),
})
: t('roadtrip.leg.pending')}
</span>
</>
)
@@ -797,22 +782,27 @@ function DriveBand({ leg, carrier, 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, continues, starts, onOpen }: {
function TerminalStop({ stop, entry, late, selected, continues, starts, onOpen }: {
stop: RoadtripStop
entry: ScheduleEntry | undefined
late: ScheduleWarning[]
selected: boolean
continues: boolean
starts?: boolean
onOpen?: () => void
}): React.ReactElement {
const { t } = useTranslation()
const Icon = carrierIcon(stop.carrier!.type)
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const carrier = stop.carrier!
const Icon = carrierIcon(carrier.type)
const line = terminalLine(carrier, t, is12h)
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}
>
@@ -823,17 +813,38 @@ function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
</span>
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
</span>
<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
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>
</button>
)
}
/**
* 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.
* 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.
*/
function RideEnd({ stop, entry, late }: {
stop: RoadtripStop
@@ -845,7 +856,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 gap-0.5">
<span className="flex min-w-0 flex-1 flex-col">
<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 ? (
@@ -921,11 +932,9 @@ 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, canEdit, onOpen }: {
function BookingChips({ bookings, continues, 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()
@@ -947,7 +956,7 @@ function BookingChips({ bookings, continues, canEdit, 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 && bookingOpens(r, canEdit) ? (
return onOpen ? (
<Tooltip key={r.id} label={t('roadtrip.ride.open')}>
<button
type="button"
@@ -1508,7 +1517,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, canEditBookings, reservations, dayOrder, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
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 }: {
onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
day: RoadtripDay
/** Folded down to the header, and off the map with it. */
@@ -1523,10 +1532,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
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']
@@ -1543,7 +1549,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
// 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 ?? [], dayOrder), [day, reservations, dayOrder])
const bookings = useMemo(() => dayBookings(day, reservations ?? []), [day, reservations])
const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const { t, language } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
@@ -1568,36 +1574,16 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
*/
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}
{refuelBandsFor(i)}
{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}
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
)) : null}
@@ -1640,20 +1626,15 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
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) ? (
/* 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} />
<DriveBand leg={day.legs[tail]} />
) : 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}
@@ -1727,7 +1708,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
/>
)}
{bookings.atStop.has(i) ? (
<BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
<BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} onOpen={onOpenBooking} />
) : null}
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
<DriveBand
@@ -1736,7 +1717,28 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
/>
) : null}
{refuelBandsFor(i)}
{/* 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}
{/* 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) => (
@@ -1753,15 +1755,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
* 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 => {
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 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 runs: { spill: SpillMark | null; from: number; to: number }[] = []
{
let at = 0
@@ -1904,9 +1909,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
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. */}
{/* 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}
{day.arrivingLeg ? <DriveBand leg={day.arrivingLeg} /> : null}
{runs.map(run => (
run.spill ? (
<SpillBlock key={`spill-${run.from}`} spill={run.spill}>
@@ -1927,7 +1930,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
<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} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
<BookingChips bookings={bookings.loose} continues={false} onOpen={onOpenBooking} />
</li>
) : null}
</ol>
@@ -2030,7 +2033,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
* them is a pill just for being a number.
*/
export default function RoadtripSidebar({
routes, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
routes, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
collapsedDayIds, onToggleDay, onFocusPoint,
}: RoadtripSidebarProps): React.ReactElement {
@@ -2040,13 +2043,6 @@ 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) {
@@ -2096,9 +2092,7 @@ export default function RoadtripSidebar({
onAskAlternatives={onAskAlternatives}
openAlternatives={openAlternatives}
onOpenBooking={onOpenBooking}
canEditBookings={canEditBookings}
reservations={reservations}
dayOrder={dayOrder}
onEditStay={onEditStay}
onSetStopKind={onSetStopKind}
onSetStopFill={onSetStopFill}
+3 -16
View File
@@ -1,13 +1,12 @@
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 { Reservation, TranslationFn } from '../../types'
import type { 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), and which of the bookings under a stop open there.
* the phone chain (#2428).
*
* 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.
@@ -40,21 +39,9 @@ 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: Pick<CarrierTerminal, 'title'> | undefined, seg: RouteSegment | undefined): string {
export function rideText(carrier: CarrierTerminal, 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, rideGaps, riddenRanges, inRiddenRange, drivenPieces } from '@trek/shared/roadtrip'
export type { LatLng, Bbox, CorridorHit, KmRange, RideGap } from '@trek/shared/roadtrip'
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround } from '@trek/shared/roadtrip'
export type { LatLng, Bbox, CorridorHit } 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-055
// FE-RTROW-001 to FE-RTROW-044
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
@@ -604,48 +604,4 @@ 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,15 +255,6 @@ 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.
*
@@ -293,7 +284,7 @@ function unfoldRides(rows: readonly RoadtripRow[]): RoadtripRow[] {
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 unfoldRides(rows)) {
for (const row of rows) {
if (row.kind === 'stop' && row.time) {
start ??= row.time
arrive = row.time
@@ -305,8 +296,7 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
}
/**
* The stop a stage ends on: the last one the chain draws, a service stop or the arrival
* terminal of a ride included.
* The stop a stage ends on: the last one the chain draws, a service stop 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
@@ -318,9 +308,8 @@ 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 {
const unfolded = unfoldRides(rows)
for (let i = unfolded.length - 1; i >= 0; i--) {
const row = unfolded[i]
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i]
if (row.kind === 'stop') return row
}
return null
@@ -366,7 +355,7 @@ export function upNextStop(
isToday: boolean,
): { row: StopRow; minutesUntil: number } | null {
if (!day || !isToday) return null
const timed = unfoldRides(roadtripRows(day))
const timed = 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,23 +55,6 @@ 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)
+1 -20
View File
@@ -1,7 +1,6 @@
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'
@@ -43,27 +42,9 @@ 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[]>()
@@ -75,7 +56,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 = spansDay(r, day.dayId, dayOrder)
const onDay = r.day_id === day.dayId || (r.end_day_id != null && r.end_day_id === day.dayId)
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,20 +283,4 @@ 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, drivenPieces, inRiddenRange, projectOntoRoute, riddenRanges, simplifyLine, type Bbox, type LatLng } from './corridor'
import { corridorTiles, projectOntoRoute, simplifyLine, sliceAtMeters, 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. Memoised by the caller: the array's identity is what
* recreates `search`, so a fresh one every render would recreate it every render.
* lands in it is dropped.
*/
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,9 +150,33 @@ 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. The MCP corridor tool cuts the same way.
const ridden = riddenRanges(spine, gaps ?? [])
const allTiles = drivenPieces(spine, ridden, window).flatMap(piece => corridorTiles(piece, widthKm))
// 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))
const tiles = allTiles.slice(0, budget.maxTiles)
const startedAt = Date.now()
const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs
@@ -197,7 +221,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 || inRiddenRange(ridden, hit.alongKm)) continue
if (!hit || hit.offRouteKm > widthKm || inGap(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,
@@ -264,7 +288,10 @@ export function useCorridorPois(
setError(failures === jobs.length && failures > 0)
setLoading(false)
})()
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gaps])
// 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])
useEffect(() => () => abortRef.current?.abort(), [])
@@ -313,37 +313,4 @@ 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, rideGaps, type LatLng } from './corridor'
import { projectOntoRoute, type LatLng } from './corridor'
import { useVehicleRange } from './useVehicleRange'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
@@ -121,9 +121,19 @@ 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 rides = useMemo(() => (day ? rideGaps(day.stops) : []), [day])
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 search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps: rides }), [options, rides]))
const search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps }), [options, gaps]))
/**
* Whether what the panel looks for has been decided yet.
@@ -197,14 +207,8 @@ 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).filter(a => a.kind !== 'leg' || !rides.some(r => r.index === a.index)),
[stopsAlongKm, rides],
)
const anchors = useMemo(() => sectionAnchors(stopsAlongKm), [stopsAlongKm])
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,49 +931,6 @@ 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, isCarrierMode, seatCarrierStops, standsAsDay, terminalAssignmentId, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
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 { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
import { resolveLegMode } from '../Planner/legMode'
@@ -248,31 +248,6 @@ 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(
@@ -330,15 +305,13 @@ 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, 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
// 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) })
return
}
/** The same stops, as one request per pair. A pair has nothing left to split. */
@@ -443,29 +416,22 @@ 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
publish()
setLegsByDay({ ...collected })
setSnapByDay({ ...collectedSnaps })
setMissedByDay({ ...collectedMisses })
// 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) 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)
if (!controller.signal.aborted) setLoading(false)
})()
return () => controller.abort()
@@ -489,10 +455,9 @@ 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. The rides
// last: a ride is never a road, whatever was fetched for its pair.
return { ...seamLegs, ...out, ...rideLegs }
}, [plan, legsByDay, seamLegs, rideLegs])
// exist the day run is the newer of the two, so it is the one to believe.
return { ...seamLegs, ...out }
}, [plan, legsByDay, seamLegs])
// Which date each stop is actually reached on. Nothing is written to make it so; see
// `nightSpill.ts`.
@@ -511,8 +476,6 @@ 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
@@ -546,7 +509,7 @@ export function useRoadtripRoutes(
}
}
return out
}, [chains, plan, quietDays, window, legsByDay, seamLegs, rideLegs, viasByDay, connectDays])
}, [chains, plan, quietDays, window, legsByDay, seamLegs, 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)
@@ -557,22 +520,6 @@ 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
@@ -587,6 +534,19 @@ 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
@@ -644,11 +644,11 @@ describe('IntegrationsTab', () => {
expect(createBtn).toBeDisabled();
});
it('FE-COMP-INTEGRATIONS-032: a refused client registration toasts the reason the server gave', async () => {
it('FE-COMP-INTEGRATIONS-032: error toast shown when create OAuth client fails', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/oauth/clients', () =>
HttpResponse.json({ error: 'Redirect URI must use HTTPS, loopback HTTP, or a private custom scheme: http://192.168.1.5/cb' }, { status: 400 })
HttpResponse.json({ error: 'server error' }, { status: 500 })
)
);
enableMcp();
@@ -657,24 +657,9 @@ describe('IntegrationsTab', () => {
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://192.168.1.5/cb');
await user.click(screen.getByRole('button', { name: /Register Client/i }));
expect(await screen.findByText(/Redirect URI must use HTTPS/)).toBeInTheDocument();
expect(screen.queryByText('Failed to register OAuth client')).not.toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-032b: a failure the server says nothing about keeps the generic text', async () => {
const user = userEvent.setup();
server.use(http.post('/api/oauth/clients', () => HttpResponse.error()));
enableMcp();
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
await user.click(screen.getByRole('button', { name: /Register Client/i }));
expect(await screen.findByText('Failed to register OAuth client')).toBeInTheDocument();
expect(await screen.findByText(/Failed to register/i)).toBeInTheDocument();
});
});
@@ -13,7 +13,6 @@ import ApiKeysSection from './ApiKeysSection'
import { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
import { useAuthStore } from '../../store/authStore'
import { getApiErrorMessage } from '../../utils/apiError'
interface OAuthPreset {
id: string
@@ -267,11 +266,8 @@ function useIntegrations() {
setOauthNewUris('')
setOauthNewScopes([])
setOauthIsMachine(false)
} catch (err) {
// The server names the rule that refused the client (a scope, the
// ten-client cap, a redirect URI it will not take); swallowing it left
// "Failed to register OAuth client" and nothing to act on.
toast.error(getApiErrorMessage(err, t('settings.oauth.toast.createError')))
} catch {
toast.error(t('settings.oauth.toast.createError'))
} finally {
setOauthCreating(false)
}
@@ -1,4 +1,4 @@
// FE-RN-001 to FE-RN-016
// FE-RN-001 to FE-RN-014
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
@@ -209,15 +209,4 @@ 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,14 +64,6 @@ 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" />
@@ -135,6 +127,12 @@ 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,7 +50,6 @@
.rn-close { box-sizing: content-box; }
.rn-panel {
position: relative;
width: min(1420px, calc(100% - 2px));
max-height: 100%;
display: grid;
@@ -68,60 +67,12 @@
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 auto; overflow-y: auto; }
.rn-release { grid-area: 1 / 1; }
.rn-note { grid-area: 2 / 1; }
.rn-panel { grid-template-columns: 1fr; grid-template-rows: auto; overflow-y: auto; }
.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 {
@@ -609,6 +560,26 @@
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,108 +12,10 @@ describe('release notice css', () => {
const notice = read('src/components/SystemNotices/releaseNotice.css')
const tokens = read('src/index.css')
const within = (source: string, selector: string): string => {
const at = source.indexOf(selector)
const block = (selector: string): string => {
const at = notice.indexOf(selector)
expect(at, `${selector} missing from releaseNotice.css`).toBeGreaterThan(-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)
return notice.slice(at, notice.indexOf('}', at) + 1)
}
const step = (name: string): number => {
@@ -145,60 +47,4 @@ 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)
}
}
}
})
})
+1 -6
View File
@@ -1501,13 +1501,8 @@ 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; gap: 4px; }
.collab-link-chip__action { width: 30px; height: 30px; margin: -4px 0; }
.collab-link-chip__actions { opacity: 1; }
}
/* Markdown in Collab Notes */
@@ -2,7 +2,6 @@ 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'
@@ -354,7 +353,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
/>
<MAdminRow
title={t('admin.placesGoogleOnly.title')}
hint={t(placesGoogleOnlyHint(hasMapsKey, placesProvider))}
hint={t(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')}
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, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl } 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]"
>
{posterlessVideo(photo) ? (
{photo.media_type === 'video' && !photo.thumbnail_path ? (
<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, Play } from 'lucide-react'
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff } 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, posterlessVideo, geoOnceErrorKey, isValidGeoPoint } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, 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,18 +33,6 @@ 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[]
@@ -604,11 +592,7 @@ export default function MJourneyEntrySheet({
}
}}
>
{posterlessVideo(gp) ? (
<ClipTile />
) : (
<img src={photoUrl(gp)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
)}
<img src={photoUrl(gp)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
</button>
))}
{availableGalleryPhotos.length === 0 && (
@@ -626,11 +610,7 @@ 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]">
{posterlessVideo(p) ? (
<ClipTile />
) : (
<img src={photoUrl(p)} alt="" className="h-full w-full object-cover" />
)}
<img src={photoUrl(p)} alt="" className="h-full w-full object-cover" />
{!readOnly && idx > 0 && photos.length > 1 && (
<button
type="button"
@@ -11,7 +11,6 @@ import MChip from '../../components/MChip'
import { MSetCard, MSetEyebrow, MSetInput, MSetTextarea, MSetButton, MSetHint, MSetRow } from './MSettingsUi'
import MConfirmSheet from './MConfirmSheet'
import MSegmented from '../../components/MSegmented'
import { getApiErrorMessage } from '../../../utils/apiError'
interface OAuthPreset {
id: string
@@ -189,11 +188,8 @@ export default function MSettingsMcp() {
setNewUris('')
setNewScopes([])
setIsMachine(false)
} catch (err) {
// The server names the rule that refused the client (a scope, the
// ten-client cap, a redirect URI it will not take); swallowing it left
// "Failed to register OAuth client" and nothing to act on.
toast.error(getApiErrorMessage(err, t('settings.oauth.toast.createError')))
} catch {
toast.error(t('settings.oauth.toast.createError'))
} finally {
setCreating(false)
}
@@ -381,11 +381,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
dayOrderMap={planner.dayOrderMap}
reservations={planner.reservations}
showReservationStats={true}
// 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}
visibleConnectionIds={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 })}
@@ -43,7 +43,6 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
const { t, trip, can } = planner
const canEdit = can('day_edit', trip)
const editing = shell.mode === 'edit' && canEdit
const canEditPlaces = can('place_edit', trip)
// Per-segment travel mode (#1281): tap a connector → pick the leg's mode.
const legMenu = useContextMenu()
const modeIcon = (key: string) => (key === 'walking' ? Footprints : key.startsWith('plugin:') ? Zap : Car)
@@ -203,7 +202,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
reorder={reorderFor(row.item)}
drag={dragFor(row)}
onOpen={() => openPlace(row.assignment)}
onEdit={canEditPlaces ? () => tl.editAssignment(row.assignment) : undefined}
onEdit={() => tl.editAssignment(row.assignment)}
onRemove={() => tl.removeAssignment(row.assignment)}
/>
{dayScheduleFor('assignment', row.assignment.id)}
@@ -124,7 +124,7 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
reorder: ReactNode
drag?: RowDrag
onOpen: () => void
onEdit?: () => void
onEdit: () => void
onRemove: () => void
}) {
const { t } = chrome
@@ -210,11 +210,9 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
</div>
{chrome.editing && (
<span className="flex flex-none items-center gap-1.5">
{onEdit && (
<ActionCircle label={t('common.edit')} onClick={onEdit}>
<Pencil size={14} strokeWidth={2} />
</ActionCircle>
)}
<ActionCircle label={t('common.edit')} onClick={onEdit}>
<Pencil size={14} strokeWidth={2} />
</ActionCircle>
<ActionCircle label={t('planner.removeFromDay')} onClick={onRemove}>
<X size={14} strokeWidth={2} />
</ActionCircle>
@@ -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 { bookingOpens, carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
import { 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,15 +252,12 @@ 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. A chip that
* opens is a button; one this reader may not open stays a plain chip, the rule the rail
* follows too (`bookingOpens`).
* 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.
*/
export function RtBookingChips({ bookings, chrome, canEdit, onOpen }: {
export function RtBookingChips({ bookings, chrome, 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 (
@@ -278,7 +275,7 @@ export function RtBookingChips({ bookings, chrome, canEdit, 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 && bookingOpens(r, canEdit) ? (
return onOpen ? (
<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, useCallback, useMemo, useRef } from 'react'
import { Fragment, useMemo, useRef } from 'react'
import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
import { showStopOnMap, useMRoadtrip } from './useMRoadtrip'
@@ -18,7 +18,6 @@ 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'
/**
@@ -85,30 +84,22 @@ 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.
// editor, and only for somebody allowed to.
const bookings = useMemo(() => {
const byIndex = stage ? dayBookings(stage, planner.reservations, dayOrder) : null
const byIndex = stage ? dayBookings(stage, planner.reservations) : 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, 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)
}, [stage, planner.reservations])
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)
}
@@ -272,7 +263,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
})
: undefined}
/>
{chips && <RtBookingChips bookings={chips} chrome={chrome} canEdit={canEditBookings} onOpen={openBooking} />}
{chips && <RtBookingChips bookings={chips} chrome={chrome} onOpen={openBooking} />}
</Fragment>
)
}
@@ -324,7 +315,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} canEdit={canEditBookings} onOpen={openBooking} />
<RtBookingChips bookings={bookings.loose} chrome={chrome} onOpen={openBooking} />
</div>
)}
</section>
@@ -4,7 +4,6 @@ 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'
@@ -21,9 +20,6 @@ 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)
@@ -48,7 +44,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
trip: planner.trip,
days: planner.days,
places: planner.places,
assignments: planner.storedAssignments,
assignments: planner.assignments,
categories: planner.categories,
dayNotes: flatNotes,
reservations: planner.reservations,
@@ -56,7 +52,6 @@ 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, selectGoogleHoldsSlot, sourceLabelFor } from '../../../../utils/placeSource'
import { offersGoogleRetry, 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 googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const hasMapsKey = useAuthStore(s => s.hasMapsKey)
const setResolving = useCallback(
(v: boolean) => {
@@ -173,9 +173,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
}
const handleSearch = async (provider?: 'google') => {
// 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()
const trimmed = query.trim()
if (!trimmed) return
setSuggestions([])
@@ -189,7 +187,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
setResolving(true)
try {
if (!provider && isMapUrl(trimmed)) {
if (isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
onPick({
@@ -341,8 +339,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, where Google holds the key slot. */}
{results.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
place on it reaches Google. */}
{results.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && (
<button
type="button"
onClick={() => handleSearch('google')}
@@ -261,6 +261,7 @@ function BookingCard({ res, planner, canEdit, compact }: {
</button>
</div>
)}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet
@@ -279,6 +279,7 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
</button>
</div>
)}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet
+1 -39
View File
@@ -1,4 +1,4 @@
// FE-PAGE-PUBLICJOURNEY-001 to FE-PAGE-PUBLICJOURNEY-023
// FE-PAGE-PUBLICJOURNEY-001 to FE-PAGE-PUBLICJOURNEY-010
import { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react';
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
@@ -580,42 +580,4 @@ 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();
});
});
+40 -68
View File
@@ -31,7 +31,6 @@ 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 }> = {
@@ -74,13 +73,6 @@ 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 {
@@ -259,14 +251,12 @@ export default function JourneyPublicPage() {
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
>
<div className={`relative h-64 w-full ${photos[0].media_type === 'video' ? 'bg-black' : ''}`}>
{!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=""
/>
)}
<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">
@@ -311,14 +301,12 @@ export default function JourneyPublicPage() {
}`}
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
>
{!posterlessVideo(p) && (
<img
src={photoUrl(p, token!, 'thumbnail')}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
<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">
@@ -339,16 +327,12 @@ export default function JourneyPublicPage() {
className="min-w-0 flex-1 cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
>
{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'
}`}
/>
)}
<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
@@ -356,32 +340,24 @@ export default function JourneyPublicPage() {
className="min-h-0 flex-1 cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}
>
{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'
}`}
/>
)}
<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 })}
>
{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'
}`}
/>
)}
<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">
@@ -553,19 +529,15 @@ export default function JourneyPublicPage() {
})
}
>
{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"
/>
)}
<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">
@@ -853,7 +825,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}/thumbnail`}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
cartoApiKey={cartoApiKey}
showMood={journey.show_mood !== 0}
@@ -269,11 +269,8 @@ 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/thumbnail');
expect(publicPhotoUrl(55)).toBe('/api/public/journey/tok-1/photos/55/original');
(mocks.captured.mobileTimeline.onEntryClick as (e: PublicEntry) => void)(buildEntry({ id: 9 }));
expect(hook.setViewingEntry).toHaveBeenCalledWith(expect.objectContaining({ id: 9 }));
+8 -47
View File
@@ -16,9 +16,7 @@ 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: ({ positions }: { positions: [number, number][] }) => (
<div data-testid="route-line" data-positions={JSON.stringify(positions)} />
),
Polyline: () => <div data-testid="route-line" />,
Tooltip: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
useMap: () => ({
fitBounds: vi.fn(),
@@ -62,10 +60,6 @@ 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();
@@ -824,7 +818,10 @@ 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 };
beforeEach(() => divIconMock().mockClear());
// 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());
it('numbers the stops by order_index, not by payload order', async () => {
await open('order-map-token', payload({
@@ -969,15 +966,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 () => {
divIconMock().mockClear();
(L.divIcon as unknown as ReturnType<typeof vi.fn>).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(iconHtml().length).toBeGreaterThan(0));
const html = iconHtml();
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));
expect(html.some((h: string) => h.includes('#ff8800'))).toBe(true);
expect(html.some((h: string) => h.includes('#6366f1'))).toBe(false);
});
@@ -1394,42 +1391,6 @@ 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 ─────────────────────────────────
+11 -22
View File
@@ -167,33 +167,22 @@ 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[]> = {};
dayStops.forEach((a, i) => {
dayAssignments.forEach((a: any, i: number) => {
if (!a.place?.id) return;
(dayOrderMap[a.place.id] ||= []).push(i + 1);
});
// 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 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);
}
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
@@ -462,9 +451,9 @@ export default function SharedTripPage() {
/>
)}
<FitBoundsToPlaces places={mapPlaces} framedOnMount={framed !== null} />
{selectedDay && dayLine.length > 1 && (
{selectedDay && mapPlaces.length > 1 && (
<Polyline
positions={dayLine.map(p => [p.lat, p.lng])}
positions={mapPlaces.map((p: any) => [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
+1 -87
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, buildReservation } from '../../tests/helpers/factories';
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore';
import { usePluginStore } from '../store/pluginStore';
@@ -173,34 +173,6 @@ 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,
@@ -281,8 +253,6 @@ beforeEach(() => {
capturedTripMembersModalProps.current = {};
capturedFileManagerProps.current = {};
capturedPlaceInspectorProps.current = {};
capturedRoadtripSidebarProps.current = {};
capturedTransportDetailModalProps.current = {};
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
@@ -1739,60 +1709,4 @@ 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,7 +608,6 @@ 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,16 +449,6 @@ 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();
+3 -5
View File
@@ -7,7 +7,6 @@ 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'
@@ -524,13 +523,12 @@ 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, or with Amap or
OpenStreetMap holding the slot, it is a promise the search
cannot keep, and the subtitle says so. */}
what the key may be spent on. Without a key 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(placesGoogleOnlyHint(hasMapsKey, placesProvider))}</p>
<p className="text-xs text-content-faint mt-0.5">{t(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')}</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. "4 of 5 on". */
/** Right-hand summary while folded, e.g. "3 of 4 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 five of them.
* then never touched, and there are four 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
+1 -33
View File
@@ -1,4 +1,4 @@
// FE-ADMHOOK-001 to FE-ADMHOOK-051
// FE-ADMHOOK-001 to FE-ADMHOOK-049
import { http, HttpResponse } from 'msw';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -276,38 +276,6 @@ 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(
+5 -15
View File
@@ -109,6 +109,11 @@ 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
@@ -197,9 +202,6 @@ 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()
@@ -362,24 +364,12 @@ 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,9 +30,6 @@ 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
}
@@ -47,9 +44,6 @@ 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,7 +5,6 @@ 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'
/**
@@ -87,14 +86,10 @@ 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. 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).
// reader has no session, so `/api/photos/:id` would answer 401.
photoUrls: (e.photos ?? [])
.filter(p => !posterlessVideo(p))
.slice(0, 3)
.map(p => `/api/public/journey/${token}/photos/${p.photo_id}/thumbnail`),
.map((p: { photo_id: number }) => `/api/public/journey/${token}/photos/${p.photo_id}/original`),
}
})
}, [mapEntries, sortedDates, token])
@@ -1,5 +1,5 @@
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
// FE-TP-ROAD-001 to FE-TP-ROAD-112
// FE-TP-ROAD-001 to FE-TP-ROAD-108
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -2422,69 +2422,3 @@ 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)
})
})
@@ -2092,21 +2092,6 @@ describe('useTripPlanner — misc state', () => {
expect(result.current.prefillCoords).toBeNull()
})
it('FE-TP-HOOK-120: without place_edit rights the place editor and the delete dialog stay shut (#2446)', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 2, role: 'user' }) })
usePermissionsStore.setState({ permissions: { place_edit: 'admin' } })
const place = buildPlace({ id: 1, lat: 1, lng: 2 })
seedTrip({ places: [place], assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place })] } })
const { result } = await renderPlanner()
act(() => { result.current.openPlaceEditor(place, 10) })
act(() => { result.current.handleDeletePlace(1) })
expect(result.current.showPlaceForm).toBe(false)
expect(result.current.editingPlace).toBeNull()
expect(result.current.deletePlaceId).toBeNull()
})
it('FE-TP-HOOK-102: a member roster refresh replaces the cached list', async () => {
seedTrip()
const { result } = await renderPlanner()
@@ -14,7 +14,6 @@ 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'
@@ -216,6 +215,7 @@ 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(',')
@@ -2095,7 +2095,6 @@ export function useTripPlanner() {
// place's lone assignment to hydrate & persist its times; with 0 or 2+
// assignments the time is ambiguous and the modal hides the fields (#1247).
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
if (!can('place_edit', trip)) return
if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') {
const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)
const entry = Object.entries(assignments).find(([, visits]) => visits.some(visit => visit.id === visitId))
@@ -2120,7 +2119,7 @@ export function useTripPlanner() {
setPlaceFormDayId(null)
setServiceStopForm(false)
setShowPlaceForm(true)
}, [can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
}, [assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
/**
* How long the drive stands here, for every stop alike.
@@ -2135,9 +2134,8 @@ export function useTripPlanner() {
}, [])
const handleDeletePlace = useCallback((placeId) => {
if (!can('place_edit', trip)) return
setDeletePlaceId(placeId)
}, [can, trip])
}, [])
const confirmDeletePlace = useCallback(async () => {
if (!deletePlaceId) return
-7
View File
@@ -52,10 +52,6 @@ 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
@@ -85,7 +81,6 @@ 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
@@ -137,7 +132,6 @@ export const useAuthStore = create<AuthState>()(
appVersion: '',
hasMapsKey: false,
hasAmapKey: false,
placesProvider: 'auto',
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
@@ -408,7 +402,6 @@ 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 }),
+14 -20
View File
@@ -194,20 +194,6 @@ 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>
/**
@@ -266,8 +252,19 @@ 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).
return { assignments: { ...state.assignments, [dayKey]: seatByOrder(existing, incoming) } }
// 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 })),
}
}
},
'assignment:updated': (payload, state) => {
const dayKey = String((payload.assignment as Assignment).day_id)
@@ -293,14 +290,11 @@ 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]: seatByOrder(target, movedAssignment),
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
}
}
},
+6 -26
View File
@@ -233,20 +233,12 @@
}
.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: 1.12; letter-spacing: -0.045em; margin: 0;
font-size: 104px; font-weight: 600; line-height: 0.9; 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 ----------------- */
@@ -435,11 +427,6 @@
.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. */
@@ -494,15 +481,7 @@
.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.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-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-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; }
@@ -751,9 +730,10 @@
.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; }
/* 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-name {
font-size: 18px; overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.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; }
+3 -87
View File
@@ -6,26 +6,10 @@ 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 within = (source: string, selector: string): string => {
const at = source.indexOf(selector)
const block = (selector: string): string => {
const at = css.indexOf(selector)
expect(at, `${selector} missing from dashboard.css`).toBeGreaterThan(-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!
return css.slice(at, css.indexOf('}', at) + 1)
}
it('FE-DASH-CSS-001: a hero title that is one long word cannot widen the card', () => {
@@ -40,72 +24,4 @@ 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))
}
})
})
+2 -33
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { googleHoldsSlot, offersGoogleRetry, placesGoogleOnlyHint, selectGoogleHoldsSlot, sourceLabelFor } from './placeSource'
import { offersGoogleRetry, sourceLabelFor } from './placeSource'
describe('offersGoogleRetry', () => {
it('FE-PLACESOURCE-001: offers Google for a list the index or OpenStreetMap answered, only where Google answers', () => {
it('FE-PLACESOURCE-001: offers Google for a list the index or OpenStreetMap answered, only where a key exists', () => {
expect(offersGoogleRetry('trek-places+openstreetmap', true)).toBe(true)
expect(offersGoogleRetry('openstreetmap', true)).toBe(true)
expect(offersGoogleRetry('trek-places+plugin', true)).toBe(true)
@@ -17,37 +17,6 @@ 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
+15 -46
View File
@@ -32,52 +32,6 @@ 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.
*
@@ -86,6 +40,21 @@ export function placesGoogleOnlyHint(hasMapsKey: boolean, placesProvider: 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
-1
View File
@@ -187,7 +187,6 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
// resolveVisibleConnectionIds returns an array, and consumers call .includes().
visibleConnections: [] as number[],
roadtripConnections: [] as number[],
toggleConnection: vi.fn(),
allConnectionsShown: false,
toggleAllConnections: vi.fn(),
+11 -38
View File
@@ -50,48 +50,21 @@ Date.prototype.toLocaleDateString = function (locales?: Intl.LocalesArgument, op
return _origToLocaleDateString.call(this, locales ?? 'en-US', options)
}
// window.matchMedia — used by dark mode / responsive components.
// Width queries are answered from window.innerWidth, which is what a test sets
// when it wants a phone viewport, and the lists re-evaluate on a resize event,
// so a component that follows the breakpoint can be driven from a test the same
// way the browser drives it. Every other query keeps the old constant false.
const mediaLists = new Set<{ media: string; matches: boolean; listeners: Set<(e: MediaQueryListEvent) => void> }>()
function widthMatches(query: string): boolean {
const max = /\(\s*max-width:\s*(\d+)px\s*\)/.exec(query)
if (max) return window.innerWidth <= Number(max[1])
const min = /\(\s*min-width:\s*(\d+)px\s*\)/.exec(query)
if (min) return window.innerWidth >= Number(min[1])
return false
}
// window.matchMedia — used by dark mode / responsive components
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => {
const entry = { media: query, matches: widthMatches(query), listeners: new Set<(e: MediaQueryListEvent) => void>() }
mediaLists.add(entry)
return {
get matches() { return entry.matches },
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn((_type: string, fn: (e: MediaQueryListEvent) => void) => entry.listeners.add(fn)),
removeEventListener: vi.fn((_type: string, fn: (e: MediaQueryListEvent) => void) => entry.listeners.delete(fn)),
dispatchEvent: vi.fn(),
}
}),
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
window.addEventListener('resize', () => {
for (const entry of mediaLists) {
const matches = widthMatches(entry.media)
if (matches === entry.matches) continue
entry.matches = matches
for (const fn of entry.listeners) fn({ matches, media: entry.media } as MediaQueryListEvent)
}
})
// IntersectionObserver — used by lazy loading
// Must use a class or regular function (not arrow function) so 'new IntersectionObserver()' works
class _MockIntersectionObserver {
@@ -347,35 +347,6 @@ describe('MAdminSettingsSection', () => {
expect(admin.setPlacesDetailsEnabledState).toHaveBeenLastCalledWith(true);
});
it('FE-MOB-ASET-018b: the Google-only row says what it needs without a key and toggles through the hook', async () => {
const user = userEvent.setup();
const admin = renderSettings();
await user.click(screen.getByRole('button', { name: 'What the key is used for' }));
expect(screen.getByText(/Needs a Google Maps API key/)).toBeInTheDocument();
expect(toggle('Search with Google only')).toHaveAttribute('aria-checked', 'false');
await user.click(toggle('Search with Google only'));
expect(admin.handleTogglePlacesGoogleOnly).toHaveBeenCalledTimes(1);
});
it('FE-MOB-ASET-018c: with a key the Google-only row explains what it changes, unless another provider holds the slot', async () => {
const user = userEvent.setup();
const { unmount } = render(<Harness admin={buildAdminHook({ hasMapsKey: true, placesGoogleOnly: true })} />);
await user.click(screen.getByRole('button', { name: 'What the key is used for' }));
expect(screen.getByText(/Every search and every suggestion goes to Google Places/)).toBeInTheDocument();
expect(toggle('Search with Google only')).toHaveAttribute('aria-checked', 'true');
unmount();
// The server ignores the switch under Amap or OpenStreetMap, and the row
// must not promise otherwise just because a key is stored.
render(<Harness admin={buildAdminHook({ hasMapsKey: true, placesGoogleOnly: true, placesProvider: 'amap' })} />);
await user.click(screen.getByRole('button', { name: 'What the key is used for' }));
expect(screen.queryByText(/Every search and every suggestion goes to Google Places/)).not.toBeInTheDocument();
expect(screen.queryByText(/Needs a Google Maps API key/)).not.toBeInTheDocument();
const row = toggle('Search with Google only').closest<HTMLElement>('.justify-between')!;
expect(row.textContent).toMatch(/provider/i);
});
it('FE-MOB-ASET-019: the API-keys card is about keys, and weather needs none', () => {
// The Open-Meteo panel left this card, as it did on the desktop tab: it takes
// no key and has nothing to configure, so it had no business in a card about
@@ -1,4 +1,4 @@
// FE-MOB-JDET-001 to FE-MOB-JDET-039
// FE-MOB-JDET-001 to FE-MOB-JDET-037
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '../../../helpers/render';
import MJourneyDetail from '../../../../src/mobile/screens/journey/MJourneyDetail';
@@ -317,15 +317,6 @@ describe('MJourneyDetail', () => {
expect(document.querySelector('.absolute.inset-0.flex.items-center')).toBeInTheDocument();
});
it('FE-MOB-JDET-039: a provider clip shows the poster the provider serves, as the desktop gallery does', () => {
// A provider row never records a local thumbnail path, so the check the desktop
// shares (posterlessVideo) must be the one used here too (#2341 follow-up).
const gallery = [buildGalleryPhoto({ media_type: 'video', provider: 'immich', asset_id: 'a1', thumbnail_path: null })];
setup({ view: 'gallery', current: buildDetail({ gallery }) });
expect(document.querySelector('img[src="/api/photos/900/thumbnail"]')).toBeInTheDocument();
expect(document.querySelector('svg.lucide-play')).toBeInTheDocument();
});
it('FE-MOB-JDET-014: uploading from the device sends the picked files and reloads', async () => {
const { hook, container } = setup({ view: 'gallery' });
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
@@ -1306,29 +1306,3 @@ describe('MJourneyEntrySheet external photos', () => {
expect(onSave.mock.calls[0][0]).toMatchObject({ type: 'entry' });
});
});
// FE-MOB-JENTRY-055 to FE-MOB-JENTRY-056: the phone half of the desktop editor's
// play badge for a clip that came up without a poster (#2341).
describe('MJourneyEntrySheet clips', () => {
it('FE-MOB-JENTRY-055: a linked clip without a poster is a play badge, not a request for its thumbnail', () => {
const clip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: null };
mountSheet(buildEntry({ id: 5, photos: [clip] }));
expect(document.querySelector('img[src="/api/photos/100/thumbnail"]')).not.toBeInTheDocument();
expect(document.querySelector('.h-16 svg.lucide-play')).toBeInTheDocument();
});
it('FE-MOB-JENTRY-056: the gallery picker draws the same badge, and a clip with a poster its poster', async () => {
const user = userEvent.setup();
const bare = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: null };
const withPoster = { ...buildGalleryPhoto(201), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' };
mountSheet(buildEntry(), { galleryPhotos: [bare, withPoster] });
await user.click(screen.getByRole('button', { name: 'From Gallery' }));
expect(document.querySelector('img[src="/api/photos/200/thumbnail"]')).not.toBeInTheDocument();
expect(document.querySelector('img[src="/api/photos/201/thumbnail"]')).toBeInTheDocument();
expect(document.querySelectorAll('svg.lucide-play')).toHaveLength(1);
});
});
@@ -265,19 +265,17 @@ describe('MSettingsMcp', () => {
expect(writeText).toHaveBeenNthCalledWith(2, 'sec-copy');
});
it('FE-MOB-SETMCP-011: a refused registration surfaces the reason the server gave and keeps the form open', async () => {
it('FE-MOB-SETMCP-011: a failed registration surfaces an error toast and keeps the form open', async () => {
const user = userEvent.setup();
server.use(http.post('/api/oauth/clients', () =>
HttpResponse.json({ error: 'Redirect URI must use HTTPS, loopback HTTP, or a private custom scheme: http://192.168.1.5/cb' }, { status: 400 })));
server.use(http.post('/api/oauth/clients', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
renderScreen();
await user.click(screen.getByRole('button', { name: 'New Client' }));
await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'Broken');
fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://192.168.1.5/cb' } });
fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://localhost' } });
await user.click(screen.getByRole('button', { name: 'Register Client' }));
await screen.findByText(/Redirect URI must use HTTPS/);
expect(screen.queryByText('Failed to register OAuth client')).toBeNull();
await screen.findByText('Failed to register OAuth client');
expect(screen.getByText('Register OAuth Client')).toBeInTheDocument();
});
@@ -94,10 +94,6 @@ describe('MExportSheet', () => {
expect(arg.trip).toBe(planner.trip)
expect(arg.days).toBe(planner.days)
expect(arg.places).toBe(planner.places)
// The raw store, not the hook's filtered list: the export applies the plan's
// own predicate, and the switch travels with it.
expect(arg.assignments).toBe(planner.storedAssignments)
expect(arg.showServiceStops).toBe(true)
expect(arg.reservations).toBe(planner.reservations)
expect(arg.categories).toBe(planner.categories)
// dayNotes come out of the store keyed by day, flattened with a numeric day_id.
@@ -14,7 +14,7 @@ import type { AlternativeOverlay } from '../../../../src/components/Roadtrip/alt
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-037
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-035
//
// The stage's pins come out of the trip store rather than the planner's map list, so the
// stage fixtures seed the store and leave `mapPlaces` to stand for what the plan tab shows.
@@ -764,37 +764,4 @@ describe('MMapArea', () => {
expect((mocks.props.places as Place[]).map(p => p.id)).toEqual([11, 20, 21])
expect(mocks.props.places).not.toBe(planner.roadtripMapPlaces)
})
it('FE-MOB-MAPAREA-037: on the stage a seamed ride draws as its booking, whether or not it was toggled on', () => {
// The ride's own leg carries no geometry (carrierLeg), so the booking's arc is the
// only thing that joins the two terminals on the map. The planner folds the seamed
// bookings into `roadtripConnections`; the stage has to read that list, not the
// plan tab's toggles, or the road ends at the departure gate and picks up again at
// the arrival with nothing between.
const base = stagePlanner(3)
const [hamburg] = stageDay().stops
const terminal = (role: 'departure' | 'arrival') => ({
...hamburg,
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
placeId: -70,
name: role === 'departure' ? 'Hamburg Airport' : 'Munich Airport',
carrier: { reservationId: 70, type: 'flight', role, title: 'LH 2020', code: null, at: null },
})
const seamed = { ...stageDay(), stops: [hamburg, terminal('departure'), terminal('arrival')] } as unknown as RoadtripDay
const planner = {
...base,
roadtripRoutes: { ...base.roadtripRoutes, days: [seamed] },
visibleConnections: [],
roadtripConnections: [70],
} as TripPlanner
const shell = stageShell()
const { rerender } = render(<MMapArea planner={planner} shell={shell} />)
expect(mocks.props.visibleConnectionIds).toEqual([70])
// The plan tab keeps the traveller's own toggles, as the desktop does off road trip mode.
rerender(<MMapArea planner={planner} shell={{ ...shell, trTab: 'plan' }} />)
expect(mocks.props.visibleConnectionIds).toBe(planner.visibleConnections)
expect(mocks.props.visibleConnectionIds).toEqual([])
})
})
@@ -622,19 +622,6 @@ describe('MPlanTimeline', () => {
expect(mocks.tl.removeAssignment).toHaveBeenCalledWith(MUSEUM)
})
it('FE-MOB-PLTL-048: without place_edit a place row keeps remove and reorder but loses its edit circle (#2446)', () => {
renderTimeline({}, { can: vi.fn((action: string) => action !== 'place_edit') as TripPlanner['can'] }, { mode: 'edit' })
// the place row keeps its remove circle and loses the edit one; the
// transport and note rows keep theirs, editing those is a day right
const row = screen.getByText('Museum').closest('[role="button"]') as HTMLElement
expect(within(row).getByLabelText('planner.removeFromDay')).toBeInTheDocument()
expect(within(row).queryByLabelText('common.edit')).not.toBeInTheDocument()
fireEvent.click(within(row).getByLabelText('planner.removeFromDay'))
expect(mocks.tl.removeAssignment).toHaveBeenCalledWith(MUSEUM)
expect(mocks.tl.editAssignment).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-037: the transit row opens the journey view from its edit circle', () => {
renderTimeline({}, {}, { mode: 'edit' })
@@ -1,15 +1,15 @@
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '../../../helpers/render'
import {
RtAutoRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome,
RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome,
} from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripRows'
import type { RoadtripRow, StopRow } from '../../../../src/components/Roadtrip/roadtripRowModel'
import type { CarrierTerminal, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
import type { Reservation, TranslationFn } from '../../../../src/types'
import type { StopRow } from '../../../../src/components/Roadtrip/roadtripRowModel'
import type { RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../../../src/types'
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
// FE-MOB-RTROW-001 to FE-MOB-RTROW-055
// FE-MOB-RTROW-001 to FE-MOB-RTROW-049
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
const t: TranslationFn = (key, params) =>
@@ -237,143 +237,6 @@ describe('RtStopRow', () => {
expect(screen.getByText('roadtrip.warn.overnight')).toBeInTheDocument()
expect(container.querySelector('.lucide-moon')).not.toBeNull()
})
it('FE-MOB-RTROW-050: a hire desk is a row on the road: the booking\'s clock under it, no number, and a disc nobody can turn into a pump', () => {
const onPickKind = vi.fn()
const desk = stopRow({
number: null,
time: '09:00',
stop: { ...stopRow().stop, assignmentId: -3000000180, placeId: -90, name: 'Sixt Hauptbahnhof', carrier: carrier('pickup') },
})
const { container } = render(<RtStopRow row={desk} chrome={chrome} onOpen={vi.fn()} onPickKind={onPickKind} />)
expect(screen.getByText('Sixt Hauptbahnhof')).toBeInTheDocument()
expect(screen.getByText('roadtrip.ride.pickup:09:00')).toBeInTheDocument()
expect(screen.queryByText('2')).toBeNull()
// The booking's own icon on the disc, and the disc is not a control even for an
// editor: nobody turns a rental desk into a petrol station.
expect(container.querySelector('.lucide-car')).not.toBeNull()
expect(screen.queryByRole('button', { name: 'roadtrip.stop.makeService' })).toBeNull()
expect(screen.queryByRole('button', { name: 'roadtrip.stop.kind' })).toBeNull()
expect(screen.getAllByRole('button')).toHaveLength(1)
})
})
/** One end of a flight, or one desk of a hire car, as the routing round seams it in. */
function carrier(role: CarrierTerminal['role'], over: Partial<CarrierTerminal> = {}): CarrierTerminal {
const car = role === 'pickup' || role === 'return'
return {
reservationId: car ? 90 : 70,
type: car ? 'car' : 'flight',
role,
title: car ? 'Sixt Hamburg' : 'LH 2020',
code: role === 'departure' ? 'HAM' : role === 'arrival' ? 'MUC' : null,
at: { departure: '13:20', arrival: '14:30', pickup: '09:00', return: '11:30' }[role],
...over,
}
}
const FLIGHT_SEG = { ...SEG, distance: 0, duration: 4200, distanceText: '', durationText: '1 h 10 min' }
function rideRow(over: Partial<Extract<RoadtripRow, { kind: 'ride' }>> = {}): Extract<RoadtripRow, { kind: 'ride' }> {
const end = (role: 'departure' | 'arrival'): StopRow => stopRow({
number: null,
time: role === 'departure' ? '12:20' : '14:30',
pinned: true,
stop: {
...stopRow().stop,
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
placeId: -70,
name: role === 'departure' ? 'Hamburg Airport' : 'Munich Airport',
carrier: carrier(role),
},
})
return { kind: 'ride', index: 1, carrier: carrier('departure'), seg: FLIGHT_SEG, departure: end('departure'), arrival: end('arrival'), ...over }
}
describe('RtRideRow', () => {
it('FE-MOB-RTROW-051: a ride is one block: the booking and its minutes over both terminals, each with its code, its timetable line and its clock', () => {
render(<RtRideRow row={rideRow()} chrome={chrome} />)
expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument()
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(screen.getByText('roadtrip.ride.departure:13:20')).toBeInTheDocument()
expect(screen.getByText('12:20')).toBeInTheDocument()
expect(screen.getByText('Munich Airport')).toBeInTheDocument()
expect(screen.getByText('MUC')).toBeInTheDocument()
expect(screen.getByText('roadtrip.ride.arrival:14:30')).toBeInTheDocument()
expect(screen.getByText('14:30')).toBeInTheDocument()
// The terminals take no number: Hamburg is 1 and Munich is 2 either side of them.
expect(screen.queryByText('2')).toBeNull()
})
it('FE-MOB-RTROW-052: the block is one tap target that opens the booking, and plain without a handler', () => {
const onOpen = vi.fn()
const opens = render(<RtRideRow row={rideRow()} chrome={chrome} onOpen={onOpen} />)
// One control for the whole ride, not one per terminal.
expect(screen.getAllByRole('button')).toHaveLength(1)
fireEvent.click(screen.getByText('Hamburg Airport'))
fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min'))
expect(onOpen).toHaveBeenCalledTimes(2)
opens.unmount()
render(<RtRideRow row={rideRow()} chrome={chrome} />)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByText('Munich Airport')).toBeInTheDocument()
})
it('FE-MOB-RTROW-053: a ride the timetable gives no minutes for is still the booking, and its clocks follow the twelve hour setting', () => {
render(<RtRideRow row={rideRow({ seg: undefined })} chrome={{ ...chrome, is12h: true }} />)
expect(screen.getByText('LH 2020')).toBeInTheDocument()
expect(screen.queryByText(/LH 2020 ·/)).toBeNull()
expect(screen.getByText('roadtrip.ride.departure:1:20 PM')).toBeInTheDocument()
expect(screen.getByText('12:20 PM')).toBeInTheDocument()
expect(screen.queryByText('13:20')).toBeNull()
})
})
describe('RtBookingChips', () => {
const booking = (over: Partial<Reservation> = {}): Reservation =>
({ id: 11, trip_id: 1, title: 'Tisch Bullerei', type: 'restaurant', status: 'confirmed', day_id: 2, reservation_time: '2026-10-05T19:30', ...over }) as Reservation
it('FE-MOB-RTROW-054: a chip per booking with its name and its clock, each opening its booking, and plain without a handler', () => {
const onOpen = vi.fn()
const bookings = [booking(), booking({ id: 13, title: 'Elbphilharmonie', type: 'event', reservation_time: null })]
const opens = render(<RtBookingChips bookings={bookings} chrome={{ ...chrome, is12h: true }} canEdit onOpen={onOpen} />)
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
// The clock in the reader's own format, and none at all for a booking without one.
expect(screen.getByText('7:30 PM')).toBeInTheDocument()
expect(screen.queryByText('19:30')).toBeNull()
expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument()
expect(screen.getAllByRole('button')).toHaveLength(2)
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(onOpen).toHaveBeenCalledWith(bookings[0])
opens.unmount()
render(<RtBookingChips bookings={bookings} chrome={chrome} canEdit />)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByText('19:30')).toBeInTheDocument()
})
it('FE-MOB-RTROW-055: a reader who may not edit bookings gets a plain table chip and a transport chip that still opens', () => {
const onOpen = vi.fn()
const bookings = [booking(), booking({ id: 16, title: 'Taxi to the pier', type: 'taxi', reservation_time: null })]
render(<RtBookingChips bookings={bookings} chrome={chrome} canEdit={false} onOpen={onOpen} />)
// A transport has a detail view anybody may look at; a table has only its editor,
// so the chip stays a chip rather than a button that does nothing (#2012).
expect(screen.getByText('Tisch Bullerei').closest('button')).toBeNull()
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Taxi to the pier'))
expect(onOpen).toHaveBeenCalledWith(bookings[1])
expect(screen.getAllByRole('button')).toHaveLength(1)
})
})
describe('RtLegRow', () => {
@@ -5,11 +5,11 @@ import { resetAllStores, seedStore } from '../../../helpers/store'
import { useTripStore } from '../../../../src/store/tripStore'
import MRoadtripTab from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripTab'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Day, Place, Reservation } from '../../../../src/types'
import type { CarrierTerminal, RoadtripDay, RoadtripRoutes, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
import type { Day, Place } from '../../../../src/types'
import type { RoadtripDay, RoadtripRoutes, RouteSegment } from '@trek/shared/roadtrip'
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-057
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-053
//
// The stage bar pictures the place its day ends at. It reads that place out of the trip
// store rather than the planner, the unfiltered list, so the picture tests seed the store.
@@ -734,134 +734,6 @@ describe('MRoadtripTab', () => {
})
})
// The rides and the hire car the routing round seams into the day, and the day's
// other bookings (#2428). The rows themselves are pinned in MRoadtripRows.test; this
// is the wiring: which sheet a tap on them opens, and for whom.
describe('a ride, a hire desk and the day\'s bookings', () => {
const entry = (arrival: string, departure: string | null = arrival) => ({ arrival, departure, anchored: true, dayOffset: 0 })
const real = (assignmentId: number, placeId: number, name: string): RoadtripStop => ({
assignmentId, ownerDayId: 2, ownerIndex: 0, placeId, name,
lat: 53.55, lng: 9.99, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
} as unknown as RoadtripStop)
// A synthetic stop, keyed off its booking the way the routing round keys it.
const terminal = (carrier: CarrierTerminal, name: string): RoadtripStop => ({
...real(-(carrier.reservationId * 10 + (carrier.role === 'departure' || carrier.role === 'pickup' ? 0 : 1)), -carrier.reservationId, name),
carrier,
})
const flight = (role: 'departure' | 'arrival'): CarrierTerminal =>
({ reservationId: 70, type: 'flight', role, title: 'LH 2020', code: null, at: role === 'departure' ? '13:20' : '14:30' })
const desk = (role: 'pickup' | 'return'): CarrierTerminal =>
({ reservationId: 90, type: 'car', role, title: 'Sixt Hamburg', code: null, at: role === 'pickup' ? '09:00' : '11:30' })
/** Kyoto Station, a flight to Munich, then Munich: the ride is one block between two numbered stops. */
const flightStage = () => stage({
stops: [
real(503, 103, 'Kyoto Station'),
terminal(flight('departure'), 'Hamburg Airport'),
terminal(flight('arrival'), 'Munich Airport'),
real(505, 105, 'Munich'),
],
legs: [seg('12 km', '20 min'), { ...seg('', '1 h 10 min'), distance: 0, duration: 4200 }, seg('30 km', '35 min')],
schedule: { entries: [entry('09:00'), entry('12:20', '13:20'), entry('14:30'), entry('15:30')], warnings: [] },
legVias: [[], [], []], driveWarnings: [], spills: [], dryPoints: [],
})
/** A hire car picked up before the first stop: the desk is a row of its own on the road. */
const rentalStage = () => stage({
stops: [terminal(desk('pickup'), 'Sixt Hauptbahnhof'), real(503, 103, 'Kyoto Station'), real(505, 105, 'Munich')],
legs: [seg('12 km', '20 min'), seg('30 km', '35 min')],
schedule: { entries: [entry('09:00'), entry('09:20', '09:50'), entry('10:50')], warnings: [] },
legVias: [[], []], driveWarnings: [], spills: [], dryPoints: [],
})
const booking = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 7, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 2, ...over }) as Reservation
it('FE-MOB-RTTAB-054: a tap on a terminal opens its booking in the transport sheet, and no kind is offered on it', () => {
const { shell } = renderTab(planner({ roadtripRoutes: routes({ days: [rentalStage()] }) }))
fireEvent.click(screen.getByText('Sixt Hauptbahnhof'))
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 90 })
expect(shell.openSheet).not.toHaveBeenCalledWith('rtstop', expect.anything())
// The desk is unnumbered and its disc is no control: only the two real stops
// offer to become a stop on the way, and the count says two.
expect(screen.getAllByRole('button', { name: 'roadtrip.stop.makeService' })).toHaveLength(2)
expect(screen.getByText('roadtrip.ride.pickup:09:00')).toBeInTheDocument()
expect(screen.getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
})
it('FE-MOB-RTTAB-055: a ride is one block that opens its booking, between the two stops it joins', () => {
const { shell } = renderTab(planner({ roadtripRoutes: routes({ days: [flightStage()] }) }))
expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument()
expect(screen.getByText('roadtrip.ride.departure:13:20')).toBeInTheDocument()
expect(screen.getByText('roadtrip.ride.arrival:14:30')).toBeInTheDocument()
// Kyoto is 1 and Munich is 2: the terminals between them take no number.
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.queryByText('3')).toBeNull()
fireEvent.click(screen.getByText('Munich Airport'))
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 70 })
expect(shell.openSheet).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTTAB-056: a table hangs under its stop as a chip, one booked for no stop is listed under the day, and the night is neither', () => {
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-05-02T19:30', assignment_id: 503 }),
booking({ id: 13, title: 'Elbphilharmonie', type: 'event', reservation_time: '2026-05-02T20:00', place_id: 999 }),
booking({ id: 14, title: 'Tomorrow', type: 'event', day_id: 1, place_id: 105 }),
booking({ id: 15, title: 'Hotel Hafen', type: 'hotel', place_id: 105 }),
]
const p = planner({ roadtripRoutes: routes({ days: [flightStage()] }), reservations })
const { shell } = renderTab(p)
const kyoto = screen.getByText('Kyoto Station').closest('[role="button"]') as HTMLElement
// The chip sits in the block right after its stop's row.
expect(within(kyoto.nextElementSibling as HTMLElement).getByText('Tisch Bullerei')).toBeInTheDocument()
expect(screen.getByText('19:30')).toBeInTheDocument()
expect(screen.getByText('roadtrip.bookings.loose')).toBeInTheDocument()
expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument()
// Another day's booking is not this day's, and the night is the stay, not a chip.
expect(screen.queryByText('Tomorrow')).toBeNull()
expect(screen.queryByText('Hotel Hafen')).toBeNull()
// The chips are the stop's, not stops: the count stays at two.
expect(screen.getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
// A chip opens its booking in the editor, the way the place sheet opens a linked one.
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(p.setEditingReservation).toHaveBeenCalledWith(reservations[0])
expect(p.setShowReservationModal).toHaveBeenCalledWith(true)
expect(shell.openSheet).not.toHaveBeenCalled()
})
it('FE-MOB-RTTAB-057: a transport chip goes to the transport sheet, and a table is not even a button for a member who may not edit bookings', () => {
const reservations = [
booking({ id: 16, title: 'Taxi to the pier', type: 'taxi', reservation_time: '2026-05-02T08:15', assignment_id: 503 }),
booking({ id: 13, title: 'Elbphilharmonie', type: 'event', place_id: 999 }),
]
const p = planner({
roadtripRoutes: routes({ days: [flightStage()] }),
reservations,
can: (action: string) => action !== 'reservation_edit',
})
const { shell } = renderTab(p)
fireEvent.click(screen.getByText('Taxi to the pier'))
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 16 })
// The event has only its editor, which this member may not open, so the chip is a
// plain chip and not a control that does nothing when tapped (#2012).
expect(screen.getByText('Elbphilharmonie').closest('button')).toBeNull()
fireEvent.click(screen.getByText('Elbphilharmonie'))
expect(p.setEditingReservation).not.toHaveBeenCalled()
expect(p.setShowReservationModal).not.toHaveBeenCalled()
expect(shell.openSheet).toHaveBeenCalledTimes(1)
})
})
describe('day swipe', () => {
it('FE-MOB-RTTAB-027: steps the stage without refitting the map, and announces the day it landed on', () => {
const p = planner()
@@ -2,13 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { HttpResponse, delay, http } from 'msw'
import PlPlaceSearch from '../../../../src/mobile/screens/trip/sheets/PlPlaceSearch'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { useAuthStore } from '../../../../src/store/authStore'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { server } from '../../../helpers/msw/server'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
import { resetAllStores, seedStore } from '../../../helpers/store'
// FE-MOB-PLSRCH-001 to FE-MOB-PLSRCH-022b
// FE-MOB-PLSRCH-001 to FE-MOB-PLSRCH-018
// planner.t echoes the key, so labels/toasts are asserted as their keys.
const LOUVRE = {
@@ -43,21 +41,6 @@ function recordSearch(places: unknown[] = [LOUVRE]) {
})
}
/**
* The index answers with the wrong place; only a search sent to Google on
* purpose answers with the right one. The real source strings, so the test
* pins what the line under the list actually switches on.
*/
function recordGoogleRetry() {
return http.post('/api/maps/search', async ({ request }) => {
const body = await request.json() as Record<string, unknown>
searchBodies.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' })
})
}
function setup(plannerOverrides: Partial<TripPlanner> = {}, locationBias?: Parameters<typeof PlPlaceSearch>[0]['locationBias']) {
const onPick = vi.fn()
const onResolvingChange = vi.fn()
@@ -71,7 +54,6 @@ function setup(plannerOverrides: Partial<TripPlanner> = {}, locationBias?: Param
describe('PlPlaceSearch', () => {
beforeEach(() => {
resetAllStores()
autocompleteBodies = []
searchBodies = []
})
@@ -428,72 +410,4 @@ describe('PlPlaceSearch', () => {
fireEvent.blur(input)
await waitFor(() => expect(screen.queryByText('Louvre')).not.toBeInTheDocument())
})
it('FE-MOB-PLSRCH-019: a list the index answered offers Google instead, and the line sends the same query there alone', async () => {
seedStore(useAuthStore, { hasMapsKey: true })
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'places.searchGoogleInstead' }))
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument()
expect(searchBodies[0]).not.toHaveProperty('provider')
expect(searchBodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' })
// A list Google produced has nowhere further to go.
expect(screen.queryByRole('button', { name: 'places.searchGoogleInstead' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSRCH-020: without a Google key the list offers nothing', async () => {
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'places.searchGoogleInstead' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSRCH-021: 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 line would re-run the same search and stay.
seedStore(useAuthStore, { hasMapsKey: true, placesProvider: 'amap' })
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'places.searchGoogleInstead' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSRCH-022: the line sends the query the list came from, whatever the field holds by then', async () => {
seedStore(useAuthStore, { hasMapsKey: true })
server.use(recordAutocomplete([]), recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
// The list stays while the field is retyped; the line still means this list.
fireEvent.change(input, { target: { value: 'Kyoto' } })
fireEvent.click(screen.getByRole('button', { name: 'places.searchGoogleInstead' }))
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument()
expect(searchBodies).toHaveLength(2)
expect(searchBodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' })
})
it('FE-MOB-PLSRCH-022b: the line still works after the field was cleared', async () => {
seedStore(useAuthStore, { hasMapsKey: true })
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
// An empty field used to make the tap a silent no-op.
fireEvent.change(input, { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'places.searchGoogleInstead' }))
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument()
expect(searchBodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' })
})
})
@@ -1,6 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useTripStore } from '../../../src/store/tripStore';
import { applyStayStops } from '../../../src/store/stayStops';
import { resetAllStores } from '../../helpers/store';
import { buildDay, buildAssignment, buildPlace } from '../../helpers/factories';
import type { Assignment } from '../../../src/types';
@@ -238,13 +237,13 @@ describe('remoteEventHandler > assignments', () => {
});
it('FE-WSEVT-ASSIGN-014: assignment:moved drops a stale copy already sitting on the target day', () => {
const moved = buildAssignment({ id: 100, day_id: 20, order_index: 1 });
const moved = buildAssignment({ id: 100, day_id: 20 });
useTripStore.setState({
days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
assignments: {
'10': [moved],
// The target day still holds the previous copy plus an unrelated item.
'20': [buildAssignment({ id: 100, day_id: 20 }), buildAssignment({ id: 200, day_id: 20, order_index: 0 })],
'20': [buildAssignment({ id: 100, day_id: 20 }), buildAssignment({ id: 200, day_id: 20 })],
},
});
useTripStore.getState().handleRemoteEvent({
@@ -255,135 +254,9 @@ describe('remoteEventHandler > assignments', () => {
});
const target = useTripStore.getState().assignments['20'];
expect(target.map(a => a.id)).toEqual([200, 100]);
expect(target.map(a => a.order_index)).toEqual([0, 1]);
expect(useTripStore.getState().assignments['10']).toHaveLength(0);
});
// #2410: a stop arrives with the seat the server gave it, and the client puts it
// there rather than at the end of the day, the way the local move reducer does.
describe('seating by order_index', () => {
const seedDay = () => {
useTripStore.setState({
days: [buildDay({ id: 10 })],
assignments: {
'10': [
buildAssignment({ id: 100, day_id: 10, order_index: 0 }),
buildAssignment({ id: 101, day_id: 10, order_index: 1 }),
],
},
});
};
const day = () => useTripStore.getState().assignments['10'];
it('FE-WSEVT-ASSIGN-017: assignment:created at index 0 lands first and the others move up one', () => {
seedDay();
useTripStore.getState().handleRemoteEvent({
type: 'assignment:created',
assignment: buildAssignment({ id: 200, day_id: 10, order_index: 0 }),
});
expect(day().map(a => a.id)).toEqual([200, 100, 101]);
expect(day().map(a => a.order_index)).toEqual([0, 1, 2]);
});
it('FE-WSEVT-ASSIGN-018: an index past the end appends, and the rows already seated keep their identity', () => {
seedDay();
const [first, second] = day();
useTripStore.getState().handleRemoteEvent({
type: 'assignment:created',
assignment: buildAssignment({ id: 200, day_id: 10, order_index: 9 }),
});
expect(day().map(a => a.id)).toEqual([100, 101, 200]);
expect(day().map(a => a.order_index)).toEqual([0, 1, 2]);
expect(day()[0]).toBe(first);
expect(day()[1]).toBe(second);
});
it('FE-WSEVT-ASSIGN-019: a day held out of order is sorted before the seat is counted', () => {
useTripStore.setState({
days: [buildDay({ id: 10 })],
assignments: {
'10': [
buildAssignment({ id: 101, day_id: 10, order_index: 1 }),
buildAssignment({ id: 100, day_id: 10, order_index: 0 }),
],
},
});
useTripStore.getState().handleRemoteEvent({
type: 'assignment:created',
assignment: buildAssignment({ id: 200, day_id: 10, order_index: 1 }),
});
expect(day().map(a => a.id)).toEqual([100, 200, 101]);
expect(day().map(a => a.order_index)).toEqual([0, 1, 2]);
});
it('FE-WSEVT-ASSIGN-020: assignment:moved seats the row on its new day the same way', () => {
useTripStore.setState({
days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
assignments: {
'10': [buildAssignment({ id: 300, day_id: 10, order_index: 0 })],
'20': [
buildAssignment({ id: 100, day_id: 20, order_index: 0 }),
buildAssignment({ id: 101, day_id: 20, order_index: 1 }),
],
},
});
useTripStore.getState().handleRemoteEvent({
type: 'assignment:moved',
assignment: buildAssignment({ id: 300, day_id: 20, order_index: 0 }),
oldDayId: 10,
newDayId: 20,
});
const { assignments } = useTripStore.getState();
expect(assignments['10']).toEqual([]);
expect(assignments['20'].map(a => a.id)).toEqual([300, 100, 101]);
expect(assignments['20'].map(a => a.order_index)).toEqual([0, 1, 2]);
});
it('FE-WSEVT-ASSIGN-021: a night re-seated on its own day moves within it rather than doubling', () => {
// A check-in given a new hour arrives as assignment:moved with oldDayId === newDayId.
useTripStore.setState({
days: [buildDay({ id: 10 })],
assignments: {
'10': [
buildAssignment({ id: 100, day_id: 10, order_index: 0 }),
buildAssignment({ id: 12, day_id: 10, order_index: 1, accommodation_id: 7 }),
buildAssignment({ id: 101, day_id: 10, order_index: 2 }),
],
},
});
useTripStore.getState().handleRemoteEvent({
type: 'assignment:moved',
assignment: buildAssignment({ id: 12, day_id: 10, order_index: 0, accommodation_id: 7 }),
oldDayId: 10,
newDayId: 10,
});
expect(day().map(a => a.id)).toEqual([12, 100, 101]);
expect(day().map(a => a.order_index)).toEqual([0, 1, 2]);
});
it('FE-WSEVT-ASSIGN-022: the answer a session without a socket folds in seats the moved night as well', () => {
// The HTTP answer carries no assignment:reordered behind it, so the seat has to be
// right from the moved event alone.
useTripStore.setState({
days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
assignments: {
'10': [buildAssignment({ id: 12, day_id: 10, order_index: 0, accommodation_id: 7 })],
'20': [
buildAssignment({ id: 100, day_id: 20, order_index: 0 }),
buildAssignment({ id: 101, day_id: 20, order_index: 1 }),
],
},
});
applyStayStops({
movedAssignment: { assignment: buildAssignment({ id: 12, day_id: 20, order_index: 0, accommodation_id: 7 }), oldDayId: 10 },
});
const { assignments } = useTripStore.getState();
expect(assignments['10']).toEqual([]);
expect(assignments['20'].map(a => a.id)).toEqual([12, 100, 101]);
expect(assignments['20'].map(a => a.order_index)).toEqual([0, 1, 2]);
});
});
it('FE-WSEVT-ASSIGN-015: assignment:reordered drops ids that are no longer on the day', () => {
useTripStore.setState({
days: [buildDay({ id: 10 })],
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "@trek/root",
"version": "4.3.1",
"version": "4.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@trek/root",
"version": "4.3.1",
"version": "4.3.0",
"workspaces": [
"client",
"server",
@@ -25,7 +25,7 @@
},
"client": {
"name": "@trek/client",
"version": "4.3.1",
"version": "4.3.0",
"hasInstallScript": true,
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
@@ -21444,7 +21444,7 @@
},
"server": {
"name": "@trek/server",
"version": "4.3.1",
"version": "4.3.0",
"dependencies": {
"@aws-lite/client": "^0.23.7",
"@aws-lite/s3": "^0.2.7",
@@ -21825,7 +21825,7 @@
},
"shared": {
"name": "@trek/shared",
"version": "4.3.1",
"version": "4.3.0",
"dependencies": {
"isomorphic-dompurify": "^3.15.0",
"zod": "^4.3.6"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "4.3.1",
"version": "4.3.0",
"workspaces": [
"client",
"server",

Some files were not shown because too many files have changed in this diff Show More