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