mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
Merge pull request #2450 from liketrek/audit/431-fixes
fix(release): the audit round over v4.3.1
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 })],
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
type DayRow = { id: number; at: string | null; located: number };
|
||||
type Via = { id: number; after_order_index: number; sequence: number };
|
||||
import { carryVias, dayStops, locatedIds, seatAmong } from '../nest/accommodations/night-seat';
|
||||
|
||||
/**
|
||||
* Seat every booked night where its check-in says, the way a night booked today is
|
||||
* seated (AccommodationsService.positionForCheckIn).
|
||||
* seated (night-seat.ts, the rule AccommodationsService applies).
|
||||
*
|
||||
* The stop a booking puts on its check-in day used to go last unless a stop pinned to
|
||||
* a later hour pulled it forward, and the backfill that gave older bookings their stop
|
||||
@@ -16,103 +14,46 @@ type Via = { id: number; after_order_index: number; sequence: number };
|
||||
*
|
||||
* Only the stops a booking owns move (accommodation_id). A stop the traveller placed
|
||||
* themselves and then booked a night on is theirs, wherever it sits. The drawn roads
|
||||
* (roadtrip_vias) are pinned to positions among the day's located stops, so they are
|
||||
* carried along by the rules the service applies (AccommodationsService.reanchorVias):
|
||||
* a via follows the stop it was drawn after, a via behind a stop that is last stays
|
||||
* with it, and one left without a leg goes.
|
||||
* (roadtrip_vias) are pinned to positions among the day's located stops and are
|
||||
* carried along by the rules the service applies (carryVias): a via follows the stop
|
||||
* it was drawn after, a via behind a stop that is last stays with it, and one left
|
||||
* without a leg goes.
|
||||
*
|
||||
* Runs inside the migration's transaction. Re-runnable: a night already in its seat is
|
||||
* passed over. Returns how many nights moved.
|
||||
* Runs inside the migration's transaction. Re-runnable: the nights of a day are taken
|
||||
* in the order the rule seats them (a night without a check-in first, then by
|
||||
* check-in, ties by booking), so one pass leaves every night in its seat and a second
|
||||
* pass moves nothing. Returns how many nights moved.
|
||||
*/
|
||||
export function reseatBookedNights(db: Database.Database): number {
|
||||
const nights = db.prepare(`
|
||||
SELECT da.id, da.day_id, a.check_in
|
||||
SELECT da.id, da.day_id, a.id AS accommodation_id, a.check_in
|
||||
FROM day_assignments da
|
||||
JOIN day_accommodations a ON a.id = da.accommodation_id
|
||||
ORDER BY da.day_id, da.order_index, da.id
|
||||
`).all() as Array<{ id: number; day_id: number; check_in: string | null }>;
|
||||
// Another night on the same day counts by its check-in, so two bookings on one
|
||||
// day settle by the clock instead of by whichever was seated last.
|
||||
const dayRows = db.prepare(`
|
||||
SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at,
|
||||
(p.lat IS NOT NULL AND p.lng IS NOT NULL) AS located
|
||||
FROM day_assignments da JOIN places p ON p.id = da.place_id
|
||||
LEFT JOIN day_accommodations other ON other.id = da.accommodation_id
|
||||
WHERE da.day_id = ?
|
||||
ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC
|
||||
`);
|
||||
ORDER BY da.day_id, a.check_in IS NOT NULL, a.check_in, a.id
|
||||
`).all() as Array<{ id: number; day_id: number; accommodation_id: number; check_in: string | null }>;
|
||||
const setIndex = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
|
||||
const viasOf = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?');
|
||||
const dropVia = db.prepare('DELETE FROM roadtrip_vias WHERE id = ?');
|
||||
const moveVia = db.prepare('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ?');
|
||||
const setSequence = db.prepare('UPDATE roadtrip_vias SET sequence = ? WHERE id = ?');
|
||||
|
||||
let seated = 0;
|
||||
const roads = { moved: 0, removed: 0 };
|
||||
for (const night of nights) {
|
||||
const rows = dayRows.all(night.day_id) as DayRow[];
|
||||
const rows = dayStops(db, night.day_id);
|
||||
const own = rows.findIndex((row) => row.id === night.id);
|
||||
if (own < 0) continue;
|
||||
const others = rows.filter((row) => row.id !== night.id);
|
||||
let seat = 0;
|
||||
if (night.check_in) {
|
||||
others.forEach((row, i) => {
|
||||
if (row.at !== null && row.at <= night.check_in!) seat = i + 1;
|
||||
});
|
||||
}
|
||||
const next = [...others.slice(0, seat), rows[own]!, ...others.slice(seat)];
|
||||
if (next.every((row, i) => row.id === rows[i]!.id)) continue;
|
||||
const seat = seatAmong(others, { id: night.accommodation_id, check_in: night.check_in });
|
||||
const next = [...others.slice(0, seat), rows[own], ...others.slice(seat)];
|
||||
if (next.every((row, i) => row.id === rows[i].id)) continue;
|
||||
|
||||
const previousLocated = rows.filter((row) => row.located).map((row) => row.id);
|
||||
next.forEach((row, i) => setIndex.run(i, row.id));
|
||||
seated += 1;
|
||||
|
||||
const nextLocated = next.filter((row) => row.located).map((row) => row.id);
|
||||
const vias = viasOf.all(night.day_id) as Via[];
|
||||
if (!vias.length) continue;
|
||||
const previousLast = previousLocated.length - 1;
|
||||
const nextLast = nextLocated.length - 1;
|
||||
const lastStayed = previousLast >= 0 && previousLocated[previousLast] === nextLocated[nextLast];
|
||||
const landed = new Set<number>();
|
||||
const kept: Array<Via & { was: number }> = [];
|
||||
for (const via of vias) {
|
||||
const leg = lastStayed && via.after_order_index === previousLast
|
||||
? nextLast
|
||||
: legAfter(via.after_order_index, previousLocated, nextLocated);
|
||||
if (leg === null) {
|
||||
dropVia.run(via.id);
|
||||
continue;
|
||||
}
|
||||
if (leg !== via.after_order_index) {
|
||||
moveVia.run(leg, via.id);
|
||||
landed.add(via.id);
|
||||
}
|
||||
kept.push({ ...via, was: via.after_order_index, after_order_index: leg });
|
||||
}
|
||||
// Two legs that merged carry two sequence series side by side. Renumbered the way
|
||||
// the service does it: the earlier leg's points first, then by their old sequence,
|
||||
// then by id. Only a leg that received a via is touched.
|
||||
const byLeg = new Map<number, Array<Via & { was: number }>>();
|
||||
for (const via of kept) byLeg.set(via.after_order_index, [...(byLeg.get(via.after_order_index) ?? []), via]);
|
||||
for (const onLeg of byLeg.values()) {
|
||||
if (!onLeg.some((via) => landed.has(via.id))) continue;
|
||||
onLeg.sort((a, b) => a.was - b.was || a.sequence - b.sequence || a.id - b.id);
|
||||
onLeg.forEach((via, i) => {
|
||||
if (via.sequence !== i) setSequence.run(i, via.id);
|
||||
});
|
||||
const carried = carryVias(db, night.day_id, locatedIds(rows), locatedIds(next));
|
||||
if (carried) {
|
||||
roads.moved += carried.moved;
|
||||
roads.removed += carried.removed;
|
||||
}
|
||||
}
|
||||
if (roads.moved || roads.removed) {
|
||||
console.log(`[DB] Re-pinned ${roads.moved} drawn road(s) behind the reseated nights, dropped ${roads.removed} left without a leg`);
|
||||
}
|
||||
return seated;
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg a via pinned behind the n-th stop of the old order is on in the new one
|
||||
* (AccommodationsService.legAfter). Null when no leg is left for it.
|
||||
*/
|
||||
function legAfter(index: number, previousIds: number[], nextIds: number[]): number | null {
|
||||
if (index > previousIds.length - 1) return null;
|
||||
let at = index;
|
||||
while (at >= 0 && !nextIds.includes(previousIds[at]!)) at -= 1;
|
||||
if (at < 0) return null;
|
||||
const next = nextIds.indexOf(previousIds[at]!);
|
||||
return next >= nextIds.length - 1 ? null : next;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DatabaseService, type PlaceWithTags, type TripAccess } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { AssignmentsService } from '../assignments/assignments.service';
|
||||
import { carryVias, dayStops, locatedStopIds, reseatOwnStop, seatHolds, seatIndex, type Night } from './night-seat';
|
||||
import type { User } from '../../types';
|
||||
|
||||
type Trip = TripAccess;
|
||||
@@ -13,9 +14,6 @@ type MirroredAssignment = ReturnType<AssignmentsService['createAssignment']>;
|
||||
/** One day's drawn roads after a write re-pinned them, as the road trip broadcasts them. */
|
||||
type DayVias = { dayId: number; vias: RoadtripVia[] };
|
||||
|
||||
/** A via as the re-pinning reads it: where it is pinned and its place on that leg. */
|
||||
type PinnedVia = { id: number; after_order_index: number; sequence: number };
|
||||
|
||||
/** How a surface sends the mirror's events; see announceMirror. */
|
||||
export type MirrorSender = <E extends TrekWsTripEventName>(event: E, payload: TrekWsPayload<E>) => void;
|
||||
|
||||
@@ -271,69 +269,6 @@ export class AccommodationsService {
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the booking's check-in day on the map.
|
||||
*
|
||||
* Only the check-in day gets a stop, even for a fortnight's stay: that is the
|
||||
* day you drive there, and it is exactly what the road-trip side writes for a
|
||||
* night it books itself. The later nights ride on the stay row, which is where
|
||||
* the rail reads check-out from.
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
/**
|
||||
* Where in the day the booked night belongs.
|
||||
*
|
||||
* First. The hotel is where the day is based: the traveller checks in and the stops
|
||||
* they placed without an hour follow from there. Only a stop with a clock of its
|
||||
* own can stand ahead of it, and only when that clock is at or before the check-in:
|
||||
* a stop pinned to eight with a check-in at ten puts the night second, one pinned
|
||||
* to the afternoon does not. It used to go last unless something pinned later
|
||||
* pulled it forward, and a night booked for ten in the morning sat behind a whole
|
||||
* day of unpinned stops, reached at a quarter past twelve.
|
||||
*/
|
||||
private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number {
|
||||
if (!checkIn) return 0;
|
||||
// excludeId leaves the row being re-seated out of the chain it is measured
|
||||
// against. Without it a night parked at the end of the day can find itself.
|
||||
// Another booked night on the day counts by its check-in, so two bookings on
|
||||
// one day settle by the clock.
|
||||
const rows = this.db.all<{ order_index: number; at: string | null }>(`
|
||||
SELECT da.order_index, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at
|
||||
FROM day_assignments da JOIN places p ON p.id = da.place_id
|
||||
LEFT JOIN day_accommodations other ON other.id = da.accommodation_id
|
||||
WHERE da.day_id = ? AND da.id != ? ORDER BY da.order_index
|
||||
`, dayId, excludeId ?? -1);
|
||||
let seat = 0;
|
||||
for (const row of rows) if (row.at !== null && row.at <= checkIn) seat = row.order_index + 1;
|
||||
return seat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the night sits somewhere its check-in allows, on an edit that did not
|
||||
* touch the check-in.
|
||||
*
|
||||
* Read off the chain rather than recomputed, because the index a fresh insert would
|
||||
* get is not the index this row already occupies. Two ways to be wrong: something
|
||||
* with a later hour ahead of it, or something with an earlier one behind it. Stops
|
||||
* without an hour are passed over in both directions: the night may have been
|
||||
* dragged past them on purpose, and a change of notes is no reason to undo that.
|
||||
*/
|
||||
private seatedByCheckIn(dayId: number, ownId: number, checkIn: string | null | undefined): boolean {
|
||||
if (!checkIn) return true;
|
||||
const rows = this.db.all<{ id: number; at: string | null }>(`
|
||||
SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at
|
||||
FROM day_assignments da JOIN places p ON p.id = da.place_id
|
||||
LEFT JOIN day_accommodations other ON other.id = da.accommodation_id
|
||||
WHERE da.day_id = ? ORDER BY da.order_index
|
||||
`, dayId);
|
||||
const own = rows.findIndex(row => row.id === ownId);
|
||||
if (own < 0) return true;
|
||||
const laterAhead = rows.slice(0, own).some(row => row.at !== null && row.at > checkIn);
|
||||
const earlierBehind = rows.slice(own + 1).some(row => row.at !== null && row.at <= checkIn);
|
||||
return !laterAhead && !earlierBehind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the place as lodging, unless the traveller already typed it themselves.
|
||||
*
|
||||
@@ -358,58 +293,23 @@ export class AccommodationsService {
|
||||
* coordinates are never routed and so never counted.
|
||||
*/
|
||||
private stopOrders(dayIds: number[]): Map<number, number[]> {
|
||||
return new Map([...new Set(dayIds)].map((dayId): [number, number[]] => [dayId, this.locatedStopIds(dayId)]));
|
||||
}
|
||||
|
||||
private locatedStopIds(dayId: number): number[] {
|
||||
return this.db.all<{ id: number }>(`
|
||||
SELECT da.id FROM day_assignments da JOIN places p ON p.id = da.place_id
|
||||
WHERE da.day_id = ? AND p.lat IS NOT NULL AND p.lng IS NOT NULL
|
||||
ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC
|
||||
`, dayId).map(row => row.id);
|
||||
return new Map([...new Set(dayIds)].map((dayId): [number, number[]] => [dayId, locatedStopIds(this.db, dayId)]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep every drawn road behind the stop it was drawn after, now that this write
|
||||
* has seated, moved or taken out a stop on these days.
|
||||
* has seated, moved or taken out a stop on these days (carryVias has the rules).
|
||||
*
|
||||
* A night seated by its check-in ahead of the afternoon renumbers everything
|
||||
* behind it, and a via pinned to position one would otherwise bend the drive
|
||||
* into the hotel instead of the leg it was drawn on. Persisted and visible to
|
||||
* everyone, so it is put right where the stop moved, by the rules the planner
|
||||
* applies when a stop is dragged or taken out: a via follows its stop, a stop
|
||||
* that left the day hands its road to the stop before it, and a stop that is
|
||||
* last has no leg to keep a via on.
|
||||
* everyone, so it is put right where the stop moved and reported on the mirror.
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private reanchorVias(mirror: AccommodationMirror, before: Map<number, number[]>): void {
|
||||
for (const [dayId, previousIds] of before) {
|
||||
const nextIds = this.locatedStopIds(dayId);
|
||||
if (previousIds.length === nextIds.length && previousIds.every((id, i) => id === nextIds[i])) continue;
|
||||
const vias = this.db.all<PinnedVia>('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?', dayId);
|
||||
if (!vias.length) continue;
|
||||
|
||||
// A via behind the day's last stop bends the drive into the next day. It stays
|
||||
// with that stop when the stop is still last, whatever its number is now:
|
||||
// measured as a leg it would be dropped, because a last stop has no leg.
|
||||
const previousLast = previousIds.length - 1;
|
||||
const nextLast = nextIds.length - 1;
|
||||
const lastStayed = previousLast >= 0 && previousIds[previousLast] === nextIds[nextLast];
|
||||
const remove: number[] = [];
|
||||
const moved: { id: number; after_order_index: number }[] = [];
|
||||
for (const via of vias) {
|
||||
const next = lastStayed && via.after_order_index === previousLast
|
||||
? nextLast
|
||||
: this.legAfter(via.after_order_index, previousIds, nextIds);
|
||||
if (next === null) remove.push(via.id);
|
||||
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next });
|
||||
}
|
||||
if (!remove.length && !moved.length) continue;
|
||||
|
||||
for (const viaId of remove) this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', viaId, dayId);
|
||||
for (const via of moved) this.db.run('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?', via.after_order_index, via.id, dayId);
|
||||
this.renumberMergedLegs(dayId, vias.filter(via => !remove.includes(via.id)), moved);
|
||||
if (!carryVias(this.db, dayId, previousIds, locatedStopIds(this.db, dayId))) continue;
|
||||
this.noteVias(mirror, {
|
||||
dayId,
|
||||
vias: this.db.all<RoadtripVia>(
|
||||
@@ -418,47 +318,6 @@ export class AccommodationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg a via pinned behind the n-th stop of the old order is on in the new one.
|
||||
*
|
||||
* A stop this write took off the day hands its road to the stop before it: the
|
||||
* leg it was drawn on merges into the one ahead, the way taking a stop out of the
|
||||
* drive merges them (`RoadtripService.reanchor`). Null when no leg is left for it,
|
||||
* because the stop it follows is the last one now, nothing ahead of it survived,
|
||||
* or it was pinned past the day's end to begin with.
|
||||
*/
|
||||
private legAfter(index: number, previousIds: number[], nextIds: number[]): number | null {
|
||||
if (index > previousIds.length - 1) return null;
|
||||
let at = index;
|
||||
while (at >= 0 && !nextIds.includes(previousIds[at])) at -= 1;
|
||||
if (at < 0) return null;
|
||||
const next = nextIds.indexOf(previousIds[at]);
|
||||
return next >= nextIds.length - 1 ? null : next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two legs that merged carry two sequence series side by side, and everything
|
||||
* that draws the route orders by sequence: the drive would run through the first
|
||||
* leg's point, the second leg's, and back. Renumbered the way
|
||||
* `RoadtripService.reanchor` does it, the earlier leg's points first, then by
|
||||
* their old sequence, then by id. Only a leg that received a via is touched.
|
||||
*/
|
||||
private renumberMergedLegs(dayId: number, kept: PinnedVia[], moved: { id: number; after_order_index: number }[]): void {
|
||||
const landed = new Map(moved.map(via => [via.id, via.after_order_index]));
|
||||
const byLeg = new Map<number, PinnedVia[]>();
|
||||
for (const via of kept) {
|
||||
const leg = landed.get(via.id) ?? via.after_order_index;
|
||||
byLeg.set(leg, [...(byLeg.get(leg) ?? []), via]);
|
||||
}
|
||||
for (const onLeg of byLeg.values()) {
|
||||
if (!onLeg.some(via => landed.has(via.id))) continue;
|
||||
onLeg.sort((a, b) => a.after_order_index - b.after_order_index || a.sequence - b.sequence || a.id - b.id);
|
||||
onLeg.forEach((via, index) => {
|
||||
if (via.sequence !== index) this.db.run('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?', index, via.id, dayId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry per day: a write that takes a stop off a day and puts one back on
|
||||
* the same day reports the state it left behind, not both steps. */
|
||||
private noteVias(mirror: AccommodationMirror, day: DayVias): void {
|
||||
@@ -483,39 +342,25 @@ export class AccommodationsService {
|
||||
stop: { id: number; day_id: number; order_index: number },
|
||||
placeId: number,
|
||||
dayId: number,
|
||||
checkIn?: string | null,
|
||||
night: Night,
|
||||
): MirroredAssignment | null {
|
||||
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ? AND id != ?', dayId, placeId, stop.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Close the gap the row leaves behind. A no-op when it is not changing days,
|
||||
// because it is put back into that same numbering two statements down.
|
||||
this.db.run(
|
||||
'UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?',
|
||||
stop.day_id, stop.order_index,
|
||||
);
|
||||
|
||||
// Park it at the end of the target day first, then seat it by the check-in the
|
||||
// same way a fresh insert would. Two steps, because the index it should get is
|
||||
// read off the chain it is not part of yet.
|
||||
const max = this.db.get<{ max: number | null }>(
|
||||
'SELECT MAX(order_index) AS max FROM day_assignments WHERE day_id = ? AND id != ?', dayId, stop.id)!;
|
||||
const end = (max.max !== null ? max.max : -1) + 1;
|
||||
this.db.run('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?', dayId, placeId, end, stop.id);
|
||||
|
||||
const seat = this.positionForCheckIn(dayId, checkIn, stop.id);
|
||||
if (seat < end) {
|
||||
this.db.run(
|
||||
'UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?',
|
||||
dayId, seat, stop.id,
|
||||
);
|
||||
this.db.run('UPDATE day_assignments SET order_index = ? WHERE id = ?', seat, stop.id);
|
||||
}
|
||||
|
||||
reseatOwnStop(this.db, stop, placeId, dayId, night);
|
||||
return this.assignments.getAssignmentWithPlace(stop.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the booking's check-in day on the map.
|
||||
*
|
||||
* Only the check-in day gets a stop, even for a fortnight's stay: that is the
|
||||
* day you drive there, and it is exactly what the road-trip side writes for a
|
||||
* night it books itself. The later nights ride on the stay row, which is where
|
||||
* the rail reads check-out from.
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private mirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror {
|
||||
const mirror = noMirror();
|
||||
// A stay can outlive its place (place_id is ON DELETE SET NULL) and the booking
|
||||
@@ -531,8 +376,8 @@ export class AccommodationsService {
|
||||
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, placeId)) return mirror;
|
||||
|
||||
const before = this.stopOrders([dayId]);
|
||||
// Through AssignmentsService, seated where the check-in says (see
|
||||
// positionForCheckIn), with everything behind it moved up one.
|
||||
// Through AssignmentsService, seated where the check-in says (night-seat.ts),
|
||||
// with everything behind it moved up one.
|
||||
//
|
||||
// The booking id goes in with the INSERT, not as an UPDATE afterwards: what this
|
||||
// returns is the row the answer hands the client, and stamping the id on later
|
||||
@@ -541,7 +386,7 @@ export class AccommodationsService {
|
||||
// time until the next reload.
|
||||
mirror.created = this.assignments.createAssignment(dayId, placeId, null, {
|
||||
accommodationId,
|
||||
orderIndex: this.positionForCheckIn(dayId, checkIn),
|
||||
orderIndex: seatIndex(this.db, dayId, { id: accommodationId, check_in: checkIn }),
|
||||
});
|
||||
this.reanchorVias(mirror, before);
|
||||
return mirror;
|
||||
@@ -610,14 +455,17 @@ export class AccommodationsService {
|
||||
): AccommodationMirror {
|
||||
const own = this.ownStops(accommodationId);
|
||||
if (own.length === 0) return noMirror();
|
||||
const night: Night = { id: accommodationId, check_in: checkIn };
|
||||
if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) {
|
||||
// Same place, same day. A check-in given a new hour seats the night afresh, the
|
||||
// way booking it with that hour would have; any other edit leaves a stop where
|
||||
// it is unless the clocks around it say it is in the wrong place, so a night the
|
||||
// traveller dragged somewhere stays there through a change of notes.
|
||||
const settled = opts.checkInChanged
|
||||
? this.positionForCheckIn(dayId, checkIn, own[0].id) === own[0].order_index
|
||||
: this.seatedByCheckIn(dayId, own[0].id, checkIn);
|
||||
// Same place, same day. A stop already where a fresh seat would put it stays,
|
||||
// whatever the edit: relocating it would land it in the same place and report
|
||||
// a move that moved nothing. A check-in given a new hour seats the night afresh
|
||||
// otherwise, the way booking it with that hour would have; any other edit
|
||||
// leaves the stop alone unless the clocks around it say it is in the wrong
|
||||
// place, so a night the traveller dragged somewhere stays there through a
|
||||
// change of notes.
|
||||
const settled = seatIndex(this.db, dayId, night, own[0].id) === own[0].order_index
|
||||
|| (!opts.checkInChanged && seatHolds(dayStops(this.db, dayId), own[0].id, checkIn));
|
||||
if (settled) return noMirror();
|
||||
}
|
||||
|
||||
@@ -629,7 +477,7 @@ export class AccommodationsService {
|
||||
// on its id, all of which a DELETE takes with it.
|
||||
if (own.length === 1 && placeId) {
|
||||
const before = this.stopOrders([own[0].day_id, dayId]);
|
||||
const moved = this.relocateOwnStop(own[0], placeId, dayId, checkIn);
|
||||
const moved = this.relocateOwnStop(own[0], placeId, dayId, night);
|
||||
if (moved) {
|
||||
mirror.moved = { assignment: moved, oldDayId: own[0].day_id };
|
||||
mirror.stamped = this.stampLodging(placeId);
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Where a booked night sits in its day, and how the drawn roads follow when a stop
|
||||
* moves.
|
||||
*
|
||||
* One rule, applied by everything that seats a night: AccommodationsService when a
|
||||
* night is booked or edited, DaysService when a trip's dates move and carry the
|
||||
* night to another day row, and the migration that brought the trips planned under
|
||||
* the old rule along. Written over the connection's `prepare` so a raw better-sqlite3
|
||||
* handle fits as well as DatabaseService. Everything here runs inside the caller's
|
||||
* transaction.
|
||||
*/
|
||||
export interface SeatConnection {
|
||||
prepare(sql: string): Database.Statement;
|
||||
}
|
||||
|
||||
/** A stop of one day as the rule sees it: the clock it is measured by (dayStops), the
|
||||
* booking that owns it, and whether the router counts it. */
|
||||
export interface SeatRow {
|
||||
id: number;
|
||||
order_index: number;
|
||||
at: string | null;
|
||||
night_id: number | null;
|
||||
located: number;
|
||||
}
|
||||
|
||||
/** The booking whose stop is being seated. */
|
||||
export interface Night {
|
||||
id: number;
|
||||
check_in: string | null | undefined;
|
||||
}
|
||||
|
||||
/** A via as the re-pinning reads it: where it is pinned and its place on that leg. */
|
||||
interface PinnedVia {
|
||||
id: number;
|
||||
after_order_index: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A day's stops in order, each with the hour it is measured by: the visit's own, else
|
||||
* the place's.
|
||||
*
|
||||
* A stop a booking owns is measured by that booking's check-in instead, and by nothing
|
||||
* else. That is the value the night seats itself by, so it has to be the value its
|
||||
* peers see as well: read the same row as an hour pinned on the stop and the two nights
|
||||
* of a day would answer the question differently depending on which of them is asking,
|
||||
* and a pass that seats them both would never settle.
|
||||
*/
|
||||
export function dayStops(db: SeatConnection, dayId: number): SeatRow[] {
|
||||
return db.prepare(`
|
||||
SELECT da.id, da.order_index,
|
||||
CASE WHEN other.id IS NULL THEN COALESCE(da.assignment_time, p.place_time) ELSE other.check_in END AS at,
|
||||
other.id AS night_id, (p.lat IS NOT NULL AND p.lng IS NOT NULL) AS located
|
||||
FROM day_assignments da JOIN places p ON p.id = da.place_id
|
||||
LEFT JOIN day_accommodations other ON other.id = da.accommodation_id
|
||||
WHERE da.day_id = ?
|
||||
ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC
|
||||
`).all(dayId) as SeatRow[];
|
||||
}
|
||||
|
||||
/** The stops the router counts, in day order: the positions the vias are pinned to. */
|
||||
export function locatedIds(rows: readonly SeatRow[]): number[] {
|
||||
return rows.filter(row => row.located).map(row => row.id);
|
||||
}
|
||||
|
||||
export function locatedStopIds(db: SeatConnection, dayId: number): number[] {
|
||||
return locatedIds(dayStops(db, dayId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `row` stands ahead of the night on its day.
|
||||
*
|
||||
* The night leads its day: the hotel is where the day is based, and the stops placed
|
||||
* without an hour follow from there. Only a stop with a clock of its own stands ahead
|
||||
* of it, and only when that clock is at or before the check-in: a stop pinned to eight
|
||||
* with a check-in at ten puts the night second, one pinned to the afternoon does not.
|
||||
*
|
||||
* Two nights on one day settle by their check-ins. When those agree, or both are
|
||||
* missing, the earlier booking leads; without that each would count the other as
|
||||
* ahead and the pair would trade places on every pass. A night without a check-in
|
||||
* leads the day outright, ahead of one with a clock.
|
||||
*
|
||||
* Among the nights of a day that is a strict order: the ones without a check-in by
|
||||
* booking, then the rest by check-in and booking. What each of them counts as ahead
|
||||
* grows along that order, which is what lets a caller seat a whole day's nights in
|
||||
* one pass and find them all settled (reseat-booked-nights.ts).
|
||||
*/
|
||||
export function standsAhead(row: SeatRow, night: Night): boolean {
|
||||
const earlierBooking = row.night_id !== null && row.night_id < night.id;
|
||||
if (!night.check_in) return row.at === null && earlierBooking;
|
||||
if (row.at === null) return row.night_id !== null;
|
||||
if (row.at === night.check_in) return row.night_id === null || earlierBooking;
|
||||
return row.at < night.check_in;
|
||||
}
|
||||
|
||||
/** The position among `others` (the day's stops without the night's own) the night
|
||||
* takes: right behind the last row that stands ahead of it, else first. */
|
||||
export function seatAmong(others: readonly SeatRow[], night: Night): number {
|
||||
let seat = 0;
|
||||
others.forEach((row, i) => {
|
||||
if (standsAhead(row, night)) seat = i + 1;
|
||||
});
|
||||
return seat;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order_index a fresh insert of the night gets, with everything from there on
|
||||
* moved down (AssignmentsService.createAssignment). `excludeId` leaves the night's
|
||||
* own row out of the chain it is measured against: without it a night parked at
|
||||
* the end of the day can find itself.
|
||||
*/
|
||||
export function seatIndex(db: SeatConnection, dayId: number, night: Night, excludeId?: number): number {
|
||||
const others = dayStops(db, dayId).filter(row => row.id !== excludeId);
|
||||
const seat = seatAmong(others, night);
|
||||
return seat === 0 ? 0 : others[seat - 1].order_index + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the night already sits somewhere its check-in allows, on an edit that did
|
||||
* not touch the check-in.
|
||||
*
|
||||
* Read off the chain rather than recomputed, because the index a fresh insert would
|
||||
* get is not the index this row occupies. Two ways to be wrong: something with a
|
||||
* later hour ahead of it, or something with an earlier one behind it. Stops without
|
||||
* an hour are passed over in both directions, and so is another night with the same
|
||||
* check-in: the two may have been dragged into this order on purpose, and a change
|
||||
* of notes is no reason to undo that.
|
||||
*/
|
||||
export function seatHolds(rows: readonly SeatRow[], ownId: number, checkIn: string | null | undefined): boolean {
|
||||
if (!checkIn) return true;
|
||||
const own = rows.findIndex(row => row.id === ownId);
|
||||
if (own < 0) return true;
|
||||
const laterAhead = rows.slice(0, own).some(row => row.at !== null && row.at > checkIn);
|
||||
const earlierBehind = rows.slice(own + 1).some(row =>
|
||||
row.at !== null && (row.night_id === null ? row.at <= checkIn : row.at < checkIn));
|
||||
return !laterAhead && !earlierBehind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry a night's own stop to `dayId` in place, seated where its check-in says.
|
||||
*
|
||||
* The gap it leaves on the day it came from is closed. Then it is parked at the end
|
||||
* of the target day and seated the way a fresh insert would be: two steps, because
|
||||
* the index it should get is read off a chain it is not part of yet.
|
||||
*/
|
||||
export function reseatOwnStop(
|
||||
db: SeatConnection,
|
||||
stop: { id: number; day_id: number; order_index: number },
|
||||
placeId: number,
|
||||
dayId: number,
|
||||
night: Night,
|
||||
): void {
|
||||
db.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?')
|
||||
.run(stop.day_id, stop.order_index);
|
||||
const max = db.prepare('SELECT MAX(order_index) AS max FROM day_assignments WHERE day_id = ? AND id != ?')
|
||||
.get(dayId, stop.id) as { max: number | null };
|
||||
const end = (max.max !== null ? max.max : -1) + 1;
|
||||
db.prepare('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?').run(dayId, placeId, end, stop.id);
|
||||
|
||||
const seat = seatIndex(db, dayId, night, stop.id);
|
||||
if (seat < end) {
|
||||
db.prepare('UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?')
|
||||
.run(dayId, seat, stop.id);
|
||||
db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?').run(seat, stop.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep every drawn road behind the stop it was drawn after, now that a write has
|
||||
* seated, moved or taken out a stop on this day. `previousIds` and `nextIds` are the
|
||||
* located stops in order before and after: the index space the vias are pinned to.
|
||||
*
|
||||
* The rules the planner applies when a stop is dragged or taken out: a via follows
|
||||
* its stop, a stop that left the day hands its road to the stop before it, and a
|
||||
* stop that is last has no leg to keep a via on. A via behind the day's last stop
|
||||
* bends the drive into the next day and stays with that stop while it is still last,
|
||||
* whatever its number is now; measured as a leg it would be dropped.
|
||||
*
|
||||
* Returns what changed, or null when nothing did.
|
||||
*/
|
||||
export function carryVias(db: SeatConnection, dayId: number, previousIds: number[], nextIds: number[]): { moved: number; removed: number } | null {
|
||||
if (previousIds.length === nextIds.length && previousIds.every((id, i) => id === nextIds[i])) return null;
|
||||
const vias = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?').all(dayId) as PinnedVia[];
|
||||
if (!vias.length) return null;
|
||||
|
||||
const previousLast = previousIds.length - 1;
|
||||
const nextLast = nextIds.length - 1;
|
||||
const lastStayed = previousLast >= 0 && previousIds[previousLast] === nextIds[nextLast];
|
||||
const remove: number[] = [];
|
||||
const moved: { id: number; after_order_index: number }[] = [];
|
||||
for (const via of vias) {
|
||||
const next = lastStayed && via.after_order_index === previousLast
|
||||
? nextLast
|
||||
: legAfter(via.after_order_index, previousIds, nextIds);
|
||||
if (next === null) remove.push(via.id);
|
||||
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next });
|
||||
}
|
||||
if (!remove.length && !moved.length) return null;
|
||||
|
||||
for (const viaId of remove) db.prepare('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?').run(viaId, dayId);
|
||||
for (const via of moved) {
|
||||
db.prepare('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?').run(via.after_order_index, via.id, dayId);
|
||||
}
|
||||
renumberMergedLegs(db, dayId, vias.filter(via => !remove.includes(via.id)), moved);
|
||||
return { moved: moved.length, removed: remove.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg a via pinned behind the n-th stop of the old order is on in the new one.
|
||||
*
|
||||
* A stop the write took off the day hands its road to the stop before it: the leg
|
||||
* it was drawn on merges into the one ahead, the way taking a stop out of the drive
|
||||
* merges them (`RoadtripService.reanchor`). Null when no leg is left for it, because
|
||||
* the stop it follows is the last one now, nothing ahead of it survived, or it was
|
||||
* pinned past the day's end to begin with. In a pure reorder every stop survives, so
|
||||
* nothing merges and each via lands on the leg of the very stop it was drawn after.
|
||||
*/
|
||||
function legAfter(index: number, previousIds: number[], nextIds: number[]): number | null {
|
||||
if (index > previousIds.length - 1) return null;
|
||||
let at = index;
|
||||
while (at >= 0 && !nextIds.includes(previousIds[at])) at -= 1;
|
||||
if (at < 0) return null;
|
||||
const next = nextIds.indexOf(previousIds[at]);
|
||||
return next >= nextIds.length - 1 ? null : next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two legs that merged carry two sequence series side by side, and everything that
|
||||
* draws the route orders by sequence: the drive would run through the first leg's
|
||||
* point, the second leg's, and back. Renumbered the way `RoadtripService.reanchor`
|
||||
* does it, the earlier leg's points first, then by their old sequence, then by id.
|
||||
* Only a leg that received a via is touched.
|
||||
*/
|
||||
function renumberMergedLegs(db: SeatConnection, dayId: number, kept: PinnedVia[], moved: { id: number; after_order_index: number }[]): void {
|
||||
const landed = new Map(moved.map(via => [via.id, via.after_order_index]));
|
||||
const byLeg = new Map<number, PinnedVia[]>();
|
||||
for (const via of kept) {
|
||||
const leg = landed.get(via.id) ?? via.after_order_index;
|
||||
byLeg.set(leg, [...(byLeg.get(leg) ?? []), via]);
|
||||
}
|
||||
for (const onLeg of byLeg.values()) {
|
||||
if (!onLeg.some(via => landed.has(via.id))) continue;
|
||||
onLeg.sort((a, b) => a.after_order_index - b.after_order_index || a.sequence - b.sequence || a.id - b.id);
|
||||
onLeg.forEach((via, index) => {
|
||||
if (via.sequence !== index) db.prepare('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?').run(index, via.id, dayId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
|
||||
import { carryVias, locatedStopIds, reseatOwnStop } from '../accommodations/night-seat';
|
||||
import { formatAssignmentWithPlace } from '../common/rowShape';
|
||||
import type { AssignmentRow, Day, DayNote, User } from '../../types';
|
||||
|
||||
@@ -354,8 +355,8 @@ export class DaysService {
|
||||
tripId: string | number,
|
||||
prevDateByDayId: Map<number, string | null>,
|
||||
): void {
|
||||
const stays = this.db.all<{ id: number; start_day_id: number; end_day_id: number }>(
|
||||
'SELECT id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?',
|
||||
const stays = this.db.all<{ id: number; start_day_id: number; end_day_id: number; check_in: string | null }>(
|
||||
'SELECT id, start_day_id, end_day_id, check_in FROM day_accommodations WHERE trip_id = ?',
|
||||
tripId
|
||||
);
|
||||
if (stays.length === 0) return;
|
||||
@@ -368,16 +369,6 @@ export class DaysService {
|
||||
ELSE :date || SUBSTR(reservation_time, 11) END
|
||||
WHERE accommodation_id = :accId AND type = 'hotel'
|
||||
`);
|
||||
// The day stop a booking wrote moves with it, the way its linked booking does.
|
||||
// Left behind it would sit on a day the traveller no longer sleeps there, with
|
||||
// nothing on screen to say why. Re-indexed to the end of the target day, because
|
||||
// its old position belonged to a day it is leaving.
|
||||
const moveStayStop = this.db.prepare(`
|
||||
UPDATE day_assignments
|
||||
SET day_id = :dayId,
|
||||
order_index = COALESCE((SELECT MAX(order_index) FROM day_assignments WHERE day_id = :dayId), -1) + 1
|
||||
WHERE accommodation_id = :accId
|
||||
`);
|
||||
|
||||
for (const stay of stays) {
|
||||
const oldStartDate = prevDateByDayId.get(stay.start_day_id);
|
||||
@@ -388,7 +379,7 @@ export class DaysService {
|
||||
if (newStart && newEnd && newStart.day_number <= newEnd.day_number
|
||||
&& (newStart.id !== stay.start_day_id || newEnd.id !== stay.end_day_id)) {
|
||||
updateStay.run(newStart.id, newEnd.id, stay.id);
|
||||
moveStayStop.run({ dayId: newStart.id, accId: stay.id });
|
||||
if (newStart.id !== stay.start_day_id) this.carryStayStop(stay, newStart.id);
|
||||
stay.start_day_id = newStart.id;
|
||||
}
|
||||
}
|
||||
@@ -401,6 +392,31 @@ export class DaysService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The day stop a booking wrote moves with it, the way its linked booking does.
|
||||
* Left behind it would sit on a day the traveller no longer sleeps there, with
|
||||
* nothing on screen to say why. Seated on its new day by its check-in like a
|
||||
* night booked there (night-seat.ts), with the drawn roads of both days re-pinned
|
||||
* behind the stops they were drawn after. A day that already holds the place under
|
||||
* a stop of the traveller's keeps that one, and the booking rides along with it.
|
||||
*/
|
||||
private carryStayStop(stay: { id: number; check_in: string | null }, dayId: number): void {
|
||||
const own = this.db.all<{ id: number; day_id: number; place_id: number; order_index: number }>(
|
||||
'SELECT id, day_id, place_id, order_index FROM day_assignments WHERE accommodation_id = ? AND day_id != ?', stay.id, dayId
|
||||
);
|
||||
for (const stop of own) {
|
||||
const before = [stop.day_id, dayId].map((id): [number, number[]] => [id, locatedStopIds(this.db, id)]);
|
||||
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, stop.place_id)) {
|
||||
this.db.run('DELETE FROM day_assignments WHERE id = ?', stop.id);
|
||||
this.db.run('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?',
|
||||
stop.day_id, stop.order_index);
|
||||
} else {
|
||||
reseatOwnStop(this.db, stop, stop.place_id, dayId, { id: stay.id, check_in: stay.check_in });
|
||||
}
|
||||
for (const [id, previousIds] of before) carryVias(this.db, id, previousIds, locatedStopIds(this.db, id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder whole days. `orderedIds` is the desired full sequence of this trip's
|
||||
* day ids (a permutation of the current ids).
|
||||
|
||||
@@ -765,7 +765,7 @@ export class MapsService {
|
||||
return this.isSettingDisabled('places_photos_enabled');
|
||||
}
|
||||
|
||||
// ── Controller-facing surface (unchanged signatures) ───────────────────────
|
||||
// ── Controller-facing surface ──────────────────────────────────────────────
|
||||
|
||||
search(
|
||||
userId: number,
|
||||
|
||||
@@ -167,6 +167,18 @@ export class OidcController {
|
||||
|
||||
const result = this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken);
|
||||
if ('error' in result) return f('/login?oidc_error=' + result.error);
|
||||
if (result.created) {
|
||||
// An account the callback just made is a registration, the way a password
|
||||
// signup is (auth-public.controller): the same row, plus the way in. Without
|
||||
// it an admin reading user.register for who got an account never sees the
|
||||
// SSO ones, and the log looks complete while it is not.
|
||||
this.audit.writeAudit({
|
||||
userId: result.user.id,
|
||||
action: 'user.register',
|
||||
ip: getClientIp(req),
|
||||
details: { username: result.user.username, email: result.user.email, role: result.user.role, method: 'oidc' },
|
||||
});
|
||||
}
|
||||
if (result.roleChange) {
|
||||
// The claim mapping changing someone's privileges is a security event, and
|
||||
// the row is written here because this is where the client IP is. The claim
|
||||
|
||||
@@ -611,7 +611,7 @@ export class OidcService implements OnModuleDestroy {
|
||||
userInfo: OidcUserInfo,
|
||||
config: OidcConfig,
|
||||
inviteToken?: string,
|
||||
): { user: User; roleChange?: OidcRoleChange } | { error: string } {
|
||||
): { user: User; roleChange?: OidcRoleChange; created?: true } | { error: string } {
|
||||
// Defense-in-depth for direct callers — the controller redirects on a
|
||||
// missing email before it ever calls this; the same code flows through its
|
||||
// `oidc_error=' + result.error` pass-through if reached here.
|
||||
@@ -752,7 +752,9 @@ export class OidcService implements OnModuleDestroy {
|
||||
// is_guest, created_at, …) instead of a hand-built partial — same shape
|
||||
// the existing-user branch returns.
|
||||
user = this.db.prepare('SELECT * FROM users WHERE id = ?').get(Number(result.lastInsertRowid)) as User;
|
||||
return { user };
|
||||
// The one branch that makes an account. The caller writes the registration row
|
||||
// off this, so it has to be set here and nowhere else.
|
||||
return { user, created: true };
|
||||
} catch (err) {
|
||||
if (err === inviteRaceError) {
|
||||
console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exhausted — concurrent callback won the last slot`);
|
||||
|
||||
@@ -301,12 +301,12 @@ export class PlacesMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'search_place',
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap only: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.',
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap, or Google Places alone with provider "google" and whenever the admin switch "Search with Google only" is on: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
||||
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'),
|
||||
lang: z.string().max(35).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja". Defaults to English'),
|
||||
provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored when the instance has no Google key'),
|
||||
provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored unless Google holds the keyed slot: an instance without a Google key, or one whose admin picked Amap or OpenStreetMap as the places provider, answers from the TREK index and OpenStreetMap as usual'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'places', mode: 'read' },
|
||||
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
type RoadtripPlanRequest,
|
||||
type RoadtripCorridorRequest,
|
||||
} from '@trek/shared';
|
||||
import { corridorTiles, projectOntoRoute, simplifyLine } from '@trek/shared/roadtrip';
|
||||
import {
|
||||
corridorTiles,
|
||||
drivenPieces,
|
||||
inRiddenRange,
|
||||
projectOntoRoute,
|
||||
rideGaps,
|
||||
riddenRanges,
|
||||
simplifyLine,
|
||||
} from '@trek/shared/roadtrip';
|
||||
import { answeringRefusals } from './roadtrip-mcp.helpers';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -84,7 +92,7 @@ export class RoadtripPlanningMcp {
|
||||
@Tool({
|
||||
name: 'search_roadtrip_corridor',
|
||||
description:
|
||||
'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. Routing failures refuse the corridor search rather than searching an invented line.',
|
||||
'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line, and leaves out the ride between the two terminals of a flight, train, ferry, cruise or bus booking: no rectangle is searched under it and nothing found there is a hit. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. Routing failures refuse the corridor search rather than searching an invented line.',
|
||||
inputSchema: roadtripCorridorRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
@@ -105,7 +113,10 @@ export class RoadtripPlanningMcp {
|
||||
day.geometry.map(([lat, lng]) => ({ lat, lng })),
|
||||
Math.max(1, input.widthKm / 3),
|
||||
);
|
||||
const tiles = corridorTiles(line, input.widthKm);
|
||||
// The line jumps straight from a departure terminal to its arrival, and the car is
|
||||
// not on that stretch: nothing under it is tiled or on the way (#2428).
|
||||
const ridden = riddenRanges(line, rideGaps(day.stops));
|
||||
const tiles = drivenPieces(line, ridden).flatMap((piece) => corridorTiles(piece, input.widthKm));
|
||||
const hits = new Map<
|
||||
string,
|
||||
{ poi: Awaited<ReturnType<RoadtripSearchService['search']>>['pois'][number]; alongKm: number; distanceKm: number }
|
||||
@@ -123,7 +134,8 @@ export class RoadtripPlanningMcp {
|
||||
if (found.truncated || found.clamped) truncatedAreas++;
|
||||
for (const poi of found.pois) {
|
||||
const projection = projectOntoRoute(poi, line);
|
||||
if (!projection || projection.offRouteKm > input.widthKm) continue;
|
||||
if (!projection || projection.offRouteKm > input.widthKm || inRiddenRange(ridden, projection.alongKm))
|
||||
continue;
|
||||
if (
|
||||
(input.fromKm != null && projection.alongKm < input.fromKm) ||
|
||||
(input.toKm != null && projection.alongKm > input.toKm)
|
||||
|
||||
@@ -48,7 +48,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
titleKey: 'system_notice.release_notes.headline',
|
||||
bodyKey: 'system_notice.release_notes.intro',
|
||||
release: {
|
||||
version: '4.3.0',
|
||||
version: '4.3.1',
|
||||
eyebrowKey: 'system_notice.release_notes.eyebrow',
|
||||
headlineKey: 'system_notice.release_notes.headline',
|
||||
introKey: 'system_notice.release_notes.intro',
|
||||
@@ -84,7 +84,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
footnoteKey: 'system_notice.release_notes.footnote',
|
||||
notes: {
|
||||
labelKey: 'system_notice.release_notes.notes_label',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.0',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.1',
|
||||
},
|
||||
note: {
|
||||
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
|
||||
|
||||
@@ -229,8 +229,14 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
expect(res.headers.location).toContain('/login?oidc_code=');
|
||||
|
||||
// Verify user was created in DB
|
||||
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
|
||||
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get() as { id: number; username: string; role: string } | undefined;
|
||||
expect(newUser).toBeDefined();
|
||||
// Registered, the way the audit log reports a password signup, and then logged in:
|
||||
// an admin reading user.register for who got an account sees the SSO ones too.
|
||||
const rows = testDb.prepare('SELECT action, details FROM audit_log WHERE user_id = ? ORDER BY id').all(newUser!.id) as
|
||||
{ action: string; details: string | null }[];
|
||||
expect(rows.map(r => r.action)).toEqual(['user.register', 'user.login']);
|
||||
expect(JSON.parse(rows[0].details || '{}')).toEqual({ username: newUser!.username, email: 'newuser@example.com', role: newUser!.role, method: 'oidc' });
|
||||
});
|
||||
|
||||
it('OIDC-006: invalid state → redirects with invalid_state error', async () => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* A night's stop used to go last on its check-in day unless a stop pinned to a later
|
||||
* hour pulled it forward, and the backfill for older bookings appended it as well. The
|
||||
* night leads its day now (AccommodationsService.positionForCheckIn), and this step
|
||||
* brings the trips planned under the old rule along, drawn roads included.
|
||||
* night leads its day now (night-seat.ts, the rule AccommodationsService applies), and
|
||||
* this step brings the trips planned under the old rule along, drawn roads included.
|
||||
*
|
||||
* The rules live in reseat-booked-nights.ts and are tested there directly on a fully
|
||||
* migrated database: the columns the step reads (assignment_time, accommodation_id)
|
||||
@@ -189,6 +189,108 @@ describe('booked night reseat', () => {
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('RESEAT-011: two nights with the same check-in, booked in that order, stay put with their roads', () => {
|
||||
// Each used to count the other as "at or before" its check-in, so the step reported
|
||||
// two moves it never made and the reanchor dropped the road between them.
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
stop(db, first, 0, null, night(db, first, '15:00'));
|
||||
stop(db, second, 1, null, night(db, second, '15:00'));
|
||||
const between = via(db, 0);
|
||||
const intoTomorrow = via(db, 1);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(vias(db)).toEqual([
|
||||
{ id: between, after_order_index: 0, sequence: 0 },
|
||||
{ id: intoTomorrow, after_order_index: 1, sequence: 0 },
|
||||
]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('RESEAT-012: two nights with the same check-in settle by their booking, and only once', () => {
|
||||
const db = freshDb();
|
||||
const [fuel, first, second] = [place(db, 'Aral'), place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, '15:00');
|
||||
const secondNight = night(db, second, '15:00');
|
||||
stop(db, fuel, 0);
|
||||
stop(db, second, 1, null, secondNight);
|
||||
stop(db, first, 2, null, firstNight);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(2);
|
||||
|
||||
expect(order(db)).toEqual([first, second, fuel]);
|
||||
expect(indexes(db)).toEqual([0, 1, 2]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([first, second, fuel]);
|
||||
});
|
||||
|
||||
it('RESEAT-013: two nights without a check-in settle by their booking too', () => {
|
||||
// Both used to want the front, and every pass swapped them.
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, null);
|
||||
const secondNight = night(db, second, null);
|
||||
stop(db, second, 0, null, secondNight);
|
||||
stop(db, first, 1, null, firstNight);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(1);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it('RESEAT-014: a night without a check-in leads one with a clock', () => {
|
||||
const db = freshDb();
|
||||
const [fuel, clocked, open] = [place(db, 'Aral'), place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
stop(db, fuel, 0);
|
||||
stop(db, clocked, 1, null, night(db, clocked, '15:00'));
|
||||
stop(db, open, 2, null, night(db, open, null));
|
||||
|
||||
reseatBookedNights(db);
|
||||
|
||||
expect(order(db)).toEqual([open, clocked, fuel]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([open, clocked, fuel]);
|
||||
});
|
||||
|
||||
it('RESEAT-015: an hour pinned on a night own stop is not what the other nights read it by', () => {
|
||||
// The stop carries eight, the booking carries three, and a night seats itself by
|
||||
// its check-in. Read the pinned hour off the row instead and the two of them
|
||||
// answer the same question differently: each counts the other as ahead, the step
|
||||
// reports two moves on every run for ever, and the swap in between drops the road.
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, '15:00');
|
||||
const secondNight = night(db, second, '15:00');
|
||||
stop(db, first, 0, null, firstNight);
|
||||
stop(db, second, 1, '08:00', secondNight);
|
||||
const between = via(db, 0);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(vias(db)).toEqual([{ id: between, after_order_index: 0, sequence: 0 }]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('RESEAT-016: a night without a check-in leads on its booking, whatever hour its place carries', () => {
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon', '09:00'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, null);
|
||||
const secondNight = night(db, second, null);
|
||||
stop(db, second, 0, null, secondNight);
|
||||
stop(db, first, 1, null, firstNight);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(1);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it('RESEAT-008: runs again without moving anything', () => {
|
||||
const db = freshDb();
|
||||
const [fuel, hotel] = [place(db, 'Aral'), place(db, 'Rostock')];
|
||||
|
||||
@@ -789,6 +789,73 @@ describe('the day stop a booking implies', () => {
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]);
|
||||
});
|
||||
|
||||
it('ACC-022j two nights with the same check-in settle by their booking and stay settled through a change of notes', () => {
|
||||
// Each used to read the other as "at or before" its own check-in, so a notes edit on
|
||||
// the front one moved it behind the other, took the road between them with it, and
|
||||
// a notes edit on the other swapped them back.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const [first, second] = ['Adlon', 'Mercure'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
const a = svc.createAccommodation(trip.id, { place_id: first.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00' }) as any;
|
||||
const b = svc.createAccommodation(trip.id, { place_id: second.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00' }) as any;
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id]);
|
||||
const between = Number(testDb.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 52.5, 13.4)')
|
||||
.run(day.id).lastInsertRowid);
|
||||
|
||||
for (const booked of [a, b]) {
|
||||
const existing = svc.getAccommodation(booked.accommodation.id, trip.id)!;
|
||||
const quiet = svc.updateAccommodation(booked.accommodation.id, existing, { notes: 'late arrival' }) as any;
|
||||
expect(quiet.mirror.moved).toBeNull();
|
||||
expect(quiet.mirror.vias).toBeUndefined();
|
||||
}
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id]);
|
||||
expect(testDb.prepare('SELECT after_order_index FROM roadtrip_vias WHERE id = ?').get(between)).toEqual({ after_order_index: 0 });
|
||||
|
||||
// Dragged the other way round on purpose: an equal clock is no contradiction.
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = CASE place_id WHEN ? THEN 1 ELSE 0 END WHERE day_id = ?').run(first.id, day.id);
|
||||
for (const booked of [a, b]) {
|
||||
const existing = svc.getAccommodation(booked.accommodation.id, trip.id)!;
|
||||
expect((svc.updateAccommodation(booked.accommodation.id, existing, { notes: 'later' }) as any).mirror.moved).toBeNull();
|
||||
}
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([second.id, first.id]);
|
||||
});
|
||||
|
||||
it('ACC-022k two nights without a check-in: the earlier booking leads', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const [museum, first, second] = ['Pergamon', 'Adlon', 'Mercure'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
createDayAssignment(testDb, day.id, museum.id);
|
||||
svc.createAccommodation(trip.id, { place_id: first.id, start_day_id: day.id, end_day_id: day.id });
|
||||
const b = svc.createAccommodation(trip.id, { place_id: second.id, start_day_id: day.id, end_day_id: day.id }) as any;
|
||||
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]);
|
||||
const existing = svc.getAccommodation(b.accommodation.id, trip.id)!;
|
||||
expect((svc.updateAccommodation(b.accommodation.id, existing, { notes: 'late' }) as any).mirror.moved).toBeNull();
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]);
|
||||
});
|
||||
|
||||
it('ACC-022l a night on a day dragged out of clock order is settled where a fresh seat puts it', () => {
|
||||
// Pinned two, then pinned eight, on purpose. A check-in at ten goes behind the eight,
|
||||
// and nothing else satisfies both clocks; a change of notes used to relocate it to
|
||||
// the very same seat and report a move for it.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const [late, early, hotel] = ['Museum', 'Aral', 'Rostock'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(createDayAssignment(testDb, day.id, late.id).id);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '08:00' WHERE id = ?").run(createDayAssignment(testDb, day.id, early.id).id);
|
||||
const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }) as any;
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]);
|
||||
|
||||
const existing = svc.getAccommodation(accommodation.id, trip.id)!;
|
||||
const quiet = svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' }) as any;
|
||||
|
||||
expect(quiet.mirror.moved).toBeNull();
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]);
|
||||
});
|
||||
|
||||
it('ACC-023 the place is typed as lodging, so the rail draws it as a service stop', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -604,6 +604,91 @@ describe('resyncAccommodationDays', () => {
|
||||
expect(testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(res))
|
||||
.toMatchObject({ day_id: null, reservation_time: null });
|
||||
});
|
||||
|
||||
// The stop a booking wrote goes along when the stay is carried to another day row.
|
||||
const stopsOn = (dayId: number) =>
|
||||
testDb.prepare('SELECT place_id, order_index, accommodation_id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(dayId) as
|
||||
{ place_id: number; order_index: number; accommodation_id: number | null }[];
|
||||
const addVia = (dayId: number, afterOrderIndex: number) =>
|
||||
Number(testDb.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, 0, 48.1, 11.5)')
|
||||
.run(dayId, afterOrderIndex).lastInsertRowid);
|
||||
const viasOn = (dayId: number) =>
|
||||
testDb.prepare('SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ? ORDER BY id').all(dayId) as
|
||||
{ id: number; after_order_index: number }[];
|
||||
|
||||
it('DAY-SVC-055: the carried stop is seated by its check-in on its new day, and both days keep their roads in place', () => {
|
||||
// The trip gained a day at the front: d1 now holds the day before, d2 the stay's
|
||||
// date. The night used to be appended behind d2's unpinned stops, the very order
|
||||
// a night booked today no longer gets, and neither day's roads were re-pinned.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const d1 = createDay(testDb, trip.id, { date: '2026-05-01' });
|
||||
const d2 = createDay(testDb, trip.id, { date: '2026-05-02' });
|
||||
const [hotel, a, b, c, d] = ['Rostock', 'Aral', 'Hafen', 'Museum', 'Markt'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d1.id, check_in: '10:00' }) as any;
|
||||
createDayAssignment(testDb, d1.id, a.id);
|
||||
createDayAssignment(testDb, d1.id, b.id);
|
||||
createDayAssignment(testDb, d2.id, c.id);
|
||||
createDayAssignment(testDb, d2.id, d.id);
|
||||
expect(stopsOn(d1.id).map(s => s.place_id)).toEqual([hotel.id, a.id, b.id]);
|
||||
const outOfHotel = addVia(d1.id, 0);
|
||||
const outOfA = addVia(d1.id, 1);
|
||||
const outOfC = addVia(d2.id, 0);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-04-30', d1.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-01', d2.id);
|
||||
|
||||
svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']]));
|
||||
|
||||
expect(testDb.prepare('SELECT start_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id)).toMatchObject({ start_day_id: d2.id });
|
||||
expect(stopsOn(d2.id)).toEqual([
|
||||
{ place_id: hotel.id, order_index: 0, accommodation_id: accommodation.id },
|
||||
{ place_id: c.id, order_index: 1, accommodation_id: null },
|
||||
{ place_id: d.id, order_index: 2, accommodation_id: null },
|
||||
]);
|
||||
expect(stopsOn(d1.id).map(s => [s.place_id, s.order_index])).toEqual([[a.id, 0], [b.id, 1]]);
|
||||
// The hotel's road on d1 had no stop ahead to fall back on and goes; A's follows A
|
||||
// to leg zero. On d2 the road out of C is one number further on, behind C.
|
||||
expect(viasOn(d1.id)).toEqual([{ id: outOfA, after_order_index: 0 }]);
|
||||
expect(viasOn(d1.id).map(v => v.id)).not.toContain(outOfHotel);
|
||||
expect(viasOn(d2.id)).toEqual([{ id: outOfC, after_order_index: 1 }]);
|
||||
});
|
||||
|
||||
it('DAY-SVC-056: a day that already holds the place by hand keeps that stop, and the booking rides along with it', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const d1 = createDay(testDb, trip.id, { date: '2026-05-01' });
|
||||
const d2 = createDay(testDb, trip.id, { date: '2026-05-02' });
|
||||
const hotel = createPlace(testDb, trip.id, { name: 'Rostock' });
|
||||
const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d1.id }) as any;
|
||||
const own = createDayAssignment(testDb, d2.id, hotel.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-04-30', d1.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-01', d2.id);
|
||||
|
||||
svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']]));
|
||||
|
||||
expect(testDb.prepare('SELECT start_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id)).toMatchObject({ start_day_id: d2.id });
|
||||
expect(stopsOn(d1.id)).toEqual([]);
|
||||
expect(stopsOn(d2.id)).toEqual([{ place_id: hotel.id, order_index: own.order_index, accommodation_id: null }]);
|
||||
});
|
||||
|
||||
it('DAY-SVC-057: a stay whose end alone moves leaves its stop where it is', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const d1 = createDay(testDb, trip.id, { date: '2026-05-01' });
|
||||
const d2 = createDay(testDb, trip.id, { date: '2026-05-02' });
|
||||
const d3 = createDay(testDb, trip.id, { date: '2026-05-03' });
|
||||
const [hotel, a] = ['Rostock', 'Aral'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d2.id, check_in: '10:00' }) as any;
|
||||
createDayAssignment(testDb, d1.id, a.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-03', d2.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-02', d3.id);
|
||||
|
||||
svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02'], [d3.id, '2026-05-03']]));
|
||||
|
||||
expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id))
|
||||
.toMatchObject({ start_day_id: d1.id, end_day_id: d3.id });
|
||||
expect(stopsOn(d1.id).map(s => [s.place_id, s.order_index])).toEqual([[hotel.id, 0], [a.id, 1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* MAPS-AUTO-001..008 — the suggestion list behind the place search box.
|
||||
* MAPS-AUTO-001..013: the suggestion list behind the place search box.
|
||||
*
|
||||
* This is the path the index was built for. Nominatim's usage policy names
|
||||
* autocomplete as unacceptable use in its own words, whatever the rate, and the
|
||||
@@ -73,19 +73,32 @@ const osmHit = (over: Record<string, unknown> = {}) => ({
|
||||
});
|
||||
|
||||
/**
|
||||
* `enabled` drives the admin kill switch, which is read straight off
|
||||
* app_settings; an unset row reads as on, because the switch is fail-open.
|
||||
* `enabled` drives the index switch, an environment variable; `rows` are the
|
||||
* app_settings the instance holds, by key: a Google key, the provider choice,
|
||||
* the Google-only switch. Anything else reads as absent.
|
||||
*
|
||||
* Keyed on the statement rather than answering everything the same way: the
|
||||
* same `get` also resolves the Google key, and a blanket answer would hand
|
||||
* `'false'` to the key resolver and send the fallback at Google for real.
|
||||
*/
|
||||
function make(enabled = true) {
|
||||
function make(enabled = true, rows: Record<string, string> = {}) {
|
||||
trekPlaces.on = enabled;
|
||||
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
|
||||
const database = {
|
||||
get: vi.fn((sql: string, key?: unknown) =>
|
||||
typeof key === 'string' && sql.includes('app_settings') && rows[key] !== undefined ? { value: rows[key] } : undefined,
|
||||
),
|
||||
} as unknown as DatabaseService;
|
||||
return new MapsService(database, {} as PlacePhotoCacheService);
|
||||
}
|
||||
|
||||
/** Google's autocomplete envelope, which is not the shape its text search answers in. */
|
||||
const googleSuggestions = (name: string) => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
suggestions: [{ placePrediction: { placeId: 'g1', structuredFormat: { mainText: { text: name }, secondaryText: { text: 'Chiyoda' } } } }],
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockSearch.mockReset();
|
||||
mockSearch.mockResolvedValue([]);
|
||||
@@ -229,4 +242,43 @@ describe('MapsService.autocompletePlaces', () => {
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
expect(result.source).not.toBe('trek-places');
|
||||
});
|
||||
|
||||
it('MAPS-AUTO-012: the Google-only switch sends the keystroke to Google and skips the index', async () => {
|
||||
// The admin row promises every search AND every suggestion. The search half
|
||||
// is pinned next door (MAPS-SEARCH-011); this is the other call site.
|
||||
mockSearch.mockResolvedValue([hit()]);
|
||||
const fetchMock = vi.fn().mockResolvedValue(googleSuggestions('Tokyo Station'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await make(true, { maps_api_key: 'key', places_google_only: 'true' }).autocompletePlaces(1, INPUT);
|
||||
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
expect(result.source).toBe('google');
|
||||
expect(result.suggestions[0]).toMatchObject({ placeId: 'g1', mainText: 'Tokyo Station' });
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain('places:autocomplete');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('MAPS-AUTO-013: the switch changes nothing without a Google key, or when Google does not hold the slot', async () => {
|
||||
mockSearch.mockResolvedValue([hit()]);
|
||||
|
||||
// No key at all: the index answers, as before.
|
||||
const keyless = await make(true, { places_google_only: 'true' }).autocompletePlaces(1, INPUT);
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(keyless.source).toBe('trek-places');
|
||||
|
||||
// A key, but the admin picked OpenStreetMap: Google holds no slot, so
|
||||
// there is nothing for the switch to hand the keystroke to.
|
||||
mockSearch.mockClear();
|
||||
const osmOnly = await make(true, { maps_api_key: 'key', places_google_only: 'true', places_provider: 'openstreetmap' })
|
||||
.autocompletePlaces(1, INPUT);
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(osmOnly.source).toBe('trek-places');
|
||||
|
||||
// A key and the switch off: the index still answers first.
|
||||
mockSearch.mockClear();
|
||||
const off = await make(true, { maps_api_key: 'key', places_google_only: 'false' }).autocompletePlaces(1, INPUT);
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(off.source).toBe('trek-places');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,6 +221,25 @@ describe('OidcController /callback', () => {
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||
});
|
||||
|
||||
it('OIDC-CB-LOGIN-003: an account the callback creates writes the user.register row a password signup writes, then the login', async () => {
|
||||
const res = makeRes();
|
||||
const reqIp = { query: {}, headers: {}, ip: '203.0.113.9', cookies: { trek_oidc_state: 's' } } as unknown as Request;
|
||||
await ctl(svc({
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
||||
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 7, username: 'a', email: 'a@b.c', role: 'user' }, created: true }),
|
||||
})).callback('c', 's', undefined, reqIp, res);
|
||||
|
||||
expect(writeAudit).toHaveBeenCalledTimes(2);
|
||||
expect(writeAudit).toHaveBeenNthCalledWith(1, {
|
||||
userId: 7, action: 'user.register', ip: '203.0.113.9',
|
||||
details: { username: 'a', email: 'a@b.c', role: 'user', method: 'oidc' },
|
||||
});
|
||||
expect(writeAudit).toHaveBeenNthCalledWith(2, { userId: 7, action: 'user.login', ip: '203.0.113.9', details: { method: 'oidc' } });
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||
});
|
||||
|
||||
it('OIDC-CB-LOGIN-002: a login refused by the user lookup writes no login row', async () => {
|
||||
const res = makeRes();
|
||||
await ctl(svc({
|
||||
|
||||
@@ -1309,6 +1309,40 @@ describe('the day stop a hotel booking implies', () => {
|
||||
expect(stopsOn(days[0].id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('RESV-STAY-008: a save from the booking form re-seats the night only when its check-in changed', () => {
|
||||
// The form sends the whole stay on every save, check-in included, so this is the
|
||||
// door a title edit on a hotel booking comes through. Its own reading of "the
|
||||
// check-in changed" (the prior row, read before the write) decides whether a night
|
||||
// the traveller dragged is left alone or seated afresh, the way ACC-022g pins it
|
||||
// for the stay route.
|
||||
const { trip, days } = tripWithDays();
|
||||
const [harbour, market, hotel] = ['Hafen', 'Markt', 'Rostock'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
createDayAssignment(testDb, days[0].id, harbour.id);
|
||||
createDayAssignment(testDb, days[0].id, market.id);
|
||||
const stay = (check_in: string) => ({ place_id: hotel.id, start_day_id: days[0].id, end_day_id: days[0].id, check_in });
|
||||
const { reservation } = svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: stay('15:00') });
|
||||
const order = () => stopsOn(days[0].id).map(s => s.place_id);
|
||||
expect(order()).toEqual([hotel.id, harbour.id, market.id]);
|
||||
|
||||
// Dragged to the end of the day by hand.
|
||||
const own = stopsOn(days[0].id).find(s => s.place_id === hotel.id)!;
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > 0').run(days[0].id);
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id);
|
||||
expect(order()).toEqual([harbour.id, market.id, hotel.id]);
|
||||
|
||||
vi.clearAllMocks();
|
||||
let current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
||||
svc.update(String(reservation.id), String(trip.id), { title: 'Hotel, late arrival', type: 'hotel', create_accommodation: stay('15:00') } as never, current);
|
||||
expect(order()).toEqual([harbour.id, market.id, hotel.id]);
|
||||
expect(broadcast).not.toHaveBeenCalledWith(String(trip.id), 'assignment:moved', expect.anything());
|
||||
|
||||
current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
||||
svc.update(String(reservation.id), String(trip.id), { type: 'hotel', create_accommodation: stay('10:00') } as never, current);
|
||||
expect(order()).toEqual([hotel.id, harbour.id, market.id]);
|
||||
expect(stopsOn(days[0].id).map(s => s.id)).toContain(own.id);
|
||||
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'assignment:moved', expect.objectContaining({ assignment: expect.objectContaining({ id: own.id }) }));
|
||||
});
|
||||
|
||||
it('RESV-STAY-007: a foreign accommodation_id reaches no stop on the other trip', () => {
|
||||
// The trip_id guard on the stay delete is what denies that reach; the stops go
|
||||
// by accommodation id alone and would otherwise follow it straight over.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service';
|
||||
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { createDay, createDayAccommodation, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -117,6 +117,46 @@ describe('a visit end time on the road trip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a booked night on the road trip (#2410)', () => {
|
||||
// The night flag comes off the planning SELECT (stay.id), not off a row the tests
|
||||
// hand the service, so a column dropped from the statement would flip every stop
|
||||
// to a night here and nowhere else.
|
||||
it('marks the stop the night is booked at, and a day that is only its night stands among the days', async () => {
|
||||
const { user, trip, plans } = setup();
|
||||
const arrival = createDay(db, trip.id);
|
||||
const hotel = createPlace(db, trip.id, { name: 'Rostock', lat: 54.09, lng: 12.1 });
|
||||
createDayAssignment(db, arrival.id, hotel.id);
|
||||
createDayAccommodation(db, trip.id, hotel.id, arrival.id, arrival.id, { check_in: '15:00' });
|
||||
|
||||
const { context, calculated } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
expect(context.visits.map((v) => [v.name, v.stay_id !== null])).toEqual([
|
||||
['Hamburg', false],
|
||||
['Lueneburg', false],
|
||||
['Celle', false],
|
||||
['Rostock', true],
|
||||
]);
|
||||
expect(calculated.quietDays).toEqual([]);
|
||||
expect(calculated.days.map((d) => d.dayId)).toContain(arrival.id);
|
||||
const night = calculated.days.find((d) => d.dayId === arrival.id)!;
|
||||
expect(night.stops.map((s) => [s.name, s.night])).toEqual([['Rostock', true]]);
|
||||
expect(night.legs).toEqual([]);
|
||||
expect(night.schedule.entries[0]).toMatchObject({ arrival: '15:00', anchored: true });
|
||||
});
|
||||
|
||||
it('files a lone stop that is no night under the quiet days', async () => {
|
||||
const { user, trip, plans } = setup();
|
||||
const quiet = createDay(db, trip.id);
|
||||
const museum = createPlace(db, trip.id, { name: 'Museum', lat: 54.09, lng: 12.1 });
|
||||
createDayAssignment(db, quiet.id, museum.id);
|
||||
|
||||
const { calculated } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
expect(calculated.days.map((d) => d.dayId)).not.toContain(quiet.id);
|
||||
expect(calculated.quietDays.map((d) => d.dayId)).toEqual([quiet.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a booking the traveller rides (#2428)', () => {
|
||||
function withFlight(dayId: number, tripId: number, over: { end_day_id?: number; dep?: string; arr?: string } = {}) {
|
||||
const result = db
|
||||
|
||||
@@ -302,6 +302,7 @@ describe('Roadtrip MCP registration and search', () => {
|
||||
days: [
|
||||
{
|
||||
dayNumber: 1,
|
||||
stops: [],
|
||||
geometry: [
|
||||
[48, 10],
|
||||
[48, 10.1],
|
||||
@@ -388,7 +389,7 @@ describe('MCP trip preferences authorization', () => {
|
||||
describe('corridor filtering', () => {
|
||||
const input = { tripId: 10, dayNumber: 1, category: 'charging' as const, widthKm: 5, offset: 0 };
|
||||
function tool() {
|
||||
const plan = { failures: [] as unknown[], omittedVisits: [] as number[], calculated: { dayWindowIssue: null as string | null, days: [{ dayNumber: 1, geometry: [[48, 10], [48, 10.1]] }] } };
|
||||
const plan = { failures: [] as unknown[], omittedVisits: [] as number[], calculated: { dayWindowIssue: null as string | null, days: [{ dayNumber: 1, stops: [], geometry: [[48, 10], [48, 10.1]] }] } };
|
||||
const maps = { search: vi.fn(async () => ({ pois: [
|
||||
{ osm_id: 'a', name: 'Fast', lat: 48, lng: 10.03, category: 'charging', charging: { sockets: [{ type: 'type2', kw: 150 }] } },
|
||||
{ osm_id: 'b', name: 'Slow', lat: 48, lng: 10.07, category: 'charging', charging: { sockets: [{ type: 'type2', kw: 11 }] } },
|
||||
@@ -421,6 +422,44 @@ describe('corridor filtering', () => {
|
||||
expect((await read({ toKm: 0 })).hits).toHaveLength(0);
|
||||
expect((await read({})).failedSources).toEqual(['plugin']);
|
||||
});
|
||||
it('leaves the ride between two terminals out of the search and drops a hit under the flight path (#2428)', async () => {
|
||||
// The day drives to the airport, flies two degrees north and east, and drives on
|
||||
// from the far one: the geometry jumps straight between the terminals, and the
|
||||
// browser hook already cuts that stretch out. The tool tiles the same pieces.
|
||||
const terminal = (role: 'departure' | 'arrival', lat: number, lng: number) => ({
|
||||
carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: null, at: null },
|
||||
lat,
|
||||
lng,
|
||||
});
|
||||
const plan = {
|
||||
failures: [],
|
||||
omittedVisits: [],
|
||||
calculated: {
|
||||
dayWindowIssue: null,
|
||||
days: [
|
||||
{
|
||||
dayNumber: 1,
|
||||
stops: [{ lat: 48, lng: 10 }, terminal('departure', 48, 10.1), terminal('arrival', 50, 12), { lat: 50, lng: 12.1 }],
|
||||
geometry: [[48, 10], [48, 10.1], [50, 12], [50, 12.1]],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const underTheFlight = { lat: 49, lng: 11 };
|
||||
type Box = { south: number; north: number; west: number; east: number };
|
||||
const maps = { search: vi.fn(async (_query: { bbox: Box }) => ({ pois: [
|
||||
{ osm_id: 'road', name: 'On the road', lat: 48, lng: 10.05, category: 'fuel' },
|
||||
{ osm_id: 'field', name: 'Under the flight', ...underTheFlight, category: 'fuel' },
|
||||
], sources: ['osm'], failedSources: [], truncated: false, clamped: false })) };
|
||||
const mcp = new RoadtripPlanningMcp({ calculate: async () => plan } as never, maps as never, {} as never);
|
||||
const body = JSON.parse((await mcp.corridor({ ...input, category: 'fuel' }, ctx)).content[0].text as string);
|
||||
const asked = maps.search.mock.calls.map(([query]) => query.bbox);
|
||||
expect(asked.length).toBeGreaterThan(0);
|
||||
expect(asked.some((b) => b.south <= underTheFlight.lat && b.north >= underTheFlight.lat && b.west <= underTheFlight.lng && b.east >= underTheFlight.lng)).toBe(false);
|
||||
expect(body.hits.map((h: { poi: { osm_id: string } }) => h.poi.osm_id)).toEqual(['road']);
|
||||
expect(body.totalAreas).toBe(asked.length);
|
||||
expect(body.complete).toBe(true);
|
||||
});
|
||||
});
|
||||
it('reads the saved roadtrip context without routing', async () => {
|
||||
const s = setup();
|
||||
|
||||
@@ -198,6 +198,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'البحث عبر Google فقط',
|
||||
'admin.placesGoogleOnly.subtitle': 'كل بحث وكل اقتراح يذهب إلى Google Places. عند الإيقاف يجيب فهرس TREK وOpenStreetMap أولًا، ولا يُسأل Google إلا إذا لم يجدا شيئًا.',
|
||||
'admin.placesGoogleOnly.missingKey': 'يتطلب مفتاح Google Maps API. من دونه يعمل البحث عبر فهرس TREK وOpenStreetMap مهما كان وضع هذا المفتاح.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'يتطلب Google كمزود للأماكن. عند اختيار Amap أو OpenStreetMap لا يذهب أي بحث إلى Google مهما كان وضع هذا المفتاح.',
|
||||
'admin.transitProvider.title': 'مزود النقل العام',
|
||||
'admin.transitProvider.subtitle': 'الخدمة التي تجيب على بحث النقل العام.',
|
||||
'admin.transitProvider.transitous': 'Transitous (مجاني)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'حذف الرابط',
|
||||
'collab.links.edit': 'تعديل الرابط',
|
||||
'collab.links.open': 'فتح الرابط',
|
||||
'collab.links.confirmDeleteTitle': 'حذف الرابط؟',
|
||||
'collab.links.confirmDeleteBody': 'سيتم حذف هذا الرابط نهائيًا.',
|
||||
'collab.whatsNext.title': 'ما التالي',
|
||||
'collab.whatsNext.today': 'اليوم',
|
||||
'collab.whatsNext.tomorrow': 'غدًا',
|
||||
|
||||
@@ -176,6 +176,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Pesquisar apenas com o Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Toda pesquisa e toda sugestão vão para o Google Places. Desligado, o índice do TREK e o OpenStreetMap respondem primeiro, e o Google só é consultado se eles não encontrarem nada.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Precisa de uma chave de API do Google Maps. Sem ela, a pesquisa usa o índice do TREK e o OpenStreetMap, independentemente desta chave.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Precisa do Google como provedor de lugares. Com Amap ou OpenStreetMap selecionado, nenhuma busca vai ao Google, seja qual for a posição deste botão.',
|
||||
'admin.transitProvider.title': 'Provedor de transporte público',
|
||||
'admin.transitProvider.subtitle': 'Qual serviço responde à busca de transporte público.',
|
||||
'admin.transitProvider.transitous': 'Transitous (grátis)',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user