mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e79d0996ae | |||
| 8936049a81 | |||
| c0b691b8e5 | |||
| aa64572bf0 | |||
| 23aec0d898 | |||
| fdab3bb347 | |||
| d288459449 | |||
| e35d95da71 | |||
| 6b1d65b06c | |||
| b6fcaced2e | |||
| e12ae950df | |||
| 11b1399b5c | |||
| 0644106f7a | |||
| 903378f8ad | |||
| 84d3dd0c4a | |||
| bdb031a1e9 | |||
| 0b4230ff4c | |||
| fa35966ab8 | |||
| 4be2314856 | |||
| f43b89be13 | |||
| ac23773352 | |||
| 8138c83558 | |||
| 141afe708d | |||
| 43a3a5217e | |||
| d14d83ebaa | |||
| cce64c4030 | |||
| bf0507ddb5 | |||
| 9bd9ff4a80 | |||
| 8845f73eb9 | |||
| 6b09a8bc11 | |||
| 67dff455da | |||
| 97a2ea1748 | |||
| 8d70d875b3 | |||
| 3dfdc92b2a | |||
| 84b73d1a83 | |||
| eeda3188c2 | |||
| be9a38e3b0 | |||
| 417d0445b9 | |||
| a86e4b560a | |||
| cd769d9993 | |||
| 979a9283cd | |||
| 32f2488774 |
+15
-5
@@ -27,7 +27,8 @@ COPY client/ ./client/
|
||||
RUN npm run build --workspace=client
|
||||
|
||||
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
|
||||
# --ignore-scripts: nothing here needs a postinstall, tsc only reads types. The one
|
||||
# native module, better-sqlite3, ships its binaries inside the npm tarball since 13.x.
|
||||
FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -52,13 +53,22 @@ COPY server/package.json ./server/
|
||||
# changeset, so it costs nothing. Everything copied after this point carries
|
||||
# --chown=node:node for the same reason — a recursive chown in a later layer
|
||||
# would copy up every inode it touches and duplicate the whole tree in the image.
|
||||
#
|
||||
# No compiler in this stage. better-sqlite3 13.x carries prebuilt binaries for
|
||||
# amd64 and arm64 inside its tarball, so there is nothing left to build: the old
|
||||
# python3 + build-essential fallback compiled it against whatever Node headers
|
||||
# the base image had that day, and 24.19+ headers give an addon that aborts with
|
||||
# "Assertion failed: (env) != nullptr" the first time a statement is collected
|
||||
# (#2421). --ignore-scripts because a lockfile-driven npm ci still synthesises
|
||||
# `node-gyp rebuild` for the binding.gyp it finds, blind to the package's
|
||||
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
|
||||
# deps are esbuild's binary check, redundant next to its optional platform
|
||||
# package, and a telemetry ping.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
|
||||
libkitinerary-bin libsqlite3-0 && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
npm ci --workspace=server --omit=dev --ignore-scripts && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
apt-get purge -y python3 build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
|
||||
chown -R node:node /app
|
||||
|
||||
|
||||
@@ -23,3 +23,17 @@ export const rtlTextAlias = {
|
||||
'../dist/mapbox-gl-rtl-text.js',
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Same shape for Plyr's icon sprite. The package publishes its JS and CSS entries
|
||||
* and nothing else, so `plyr/dist/plyr.svg` is refused by the resolver although
|
||||
* the file is right there. The player has to point at a copy on this origin
|
||||
* (#2341): its default is the sprite on cdn.plyr.io, which the CSP refuses.
|
||||
*/
|
||||
export const plyrSpriteAlias = {
|
||||
find: /^plyr\/dist\/plyr\.svg/,
|
||||
replacement: path.join(
|
||||
path.dirname(createRequire(import.meta.url).resolve('plyr')),
|
||||
'plyr.svg',
|
||||
),
|
||||
};
|
||||
|
||||
+7
-8
@@ -320,14 +320,13 @@ export default function App() {
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
// A version is a short release tag and nothing else. It arrives over the
|
||||
// wire and is written to this device's storage, so it is rebuilt from the
|
||||
// characters a tag may contain and kept only when nothing had to be
|
||||
// stripped. Anything else is ignored, which also keeps a malformed value
|
||||
// from being compared against the stored marker and starting an update on
|
||||
// every launch.
|
||||
const reportedVersion = typeof config?.version === 'string' ? config.version : ''
|
||||
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
|
||||
if (version && version === reportedVersion) {
|
||||
// wire and is written to this device's storage, so only a value made of
|
||||
// the characters a tag may contain is taken, as the match itself. Anything
|
||||
// else is ignored, which also keeps a malformed value from being compared
|
||||
// against the stored marker and starting an update on every launch.
|
||||
const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
|
||||
if (releaseTag) {
|
||||
const version = releaseTag[0]
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
// Record the version BEFORE acting on it. The old code wrote the marker
|
||||
// after the purge and outside its try, so a throwing setItem (private
|
||||
|
||||
@@ -671,6 +671,8 @@ export const adminApi = {
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||
getPlacesGoogleOnly: () => apiClient.get('/admin/places-google-only').then(r => r.data),
|
||||
updatePlacesGoogleOnly: (enabled: boolean) => apiClient.put('/admin/places-google-only', { enabled }).then(r => r.data),
|
||||
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
|
||||
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
|
||||
getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
|
||||
@@ -1215,13 +1217,18 @@ export const mapsApi = {
|
||||
* 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.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
|
||||
/**
|
||||
* `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.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') =>
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
|
||||
// Side by side, so the wait is the slower of the two rather than their sum. Only
|
||||
// the core call may reject: that rejection is what hands withCachedPlaces the
|
||||
// offline path, and a plugin failure must never trigger it.
|
||||
const [core, extra] = await Promise.all([
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias, ...(provider ? { provider } : {}) }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
pluginSearchPlaces(query, lang, locationBias),
|
||||
])
|
||||
if (extra.length === 0) return core
|
||||
|
||||
@@ -63,7 +63,8 @@ describe('CollabLinks', () => {
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://ferries.example/timetable')).toBeInTheDocument();
|
||||
// The chip shows the host, not the whole address, and links to the address itself.
|
||||
expect(screen.getByText('ferries.example')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable');
|
||||
});
|
||||
|
||||
@@ -118,6 +119,47 @@ describe('CollabLinks', () => {
|
||||
expect(screen.getByRole('button', { name: /delete link|collab\.links\.delete/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-009: a link can be edited in place, title and address alike', async () => {
|
||||
// #2414: a link used to be delete-and-add once it needed a correction.
|
||||
const user = userEvent.setup();
|
||||
let put: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
|
||||
http.put('/api/trips/1/collab/links/1', async ({ request }) => {
|
||||
put = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ link: buildLink({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' }) });
|
||||
}),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
await user.click(await screen.findByRole('button', { name: /edit link|collab\.links\.edit/i }));
|
||||
const title = await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i);
|
||||
expect(title).toHaveValue('Ferry timetable');
|
||||
await user.clear(title);
|
||||
await user.type(title, 'Ferry timetable 2026');
|
||||
const url = screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i);
|
||||
expect(url).toHaveValue('https://ferries.example/timetable');
|
||||
await user.clear(url);
|
||||
await user.type(url, 'https://ferries.example/2026');
|
||||
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
|
||||
|
||||
await waitFor(() => expect(put).toEqual({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' }));
|
||||
await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument());
|
||||
expect(await screen.findByText('Ferry timetable 2026')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /ferry timetable 2026/i })).toHaveAttribute('href', 'https://ferries.example/2026');
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-010: the chip is the link and opens the address in a new tab', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
const open = await screen.findByRole('link', { name: /ferry timetable/i });
|
||||
expect(open).toHaveAttribute('href', 'https://ferries.example/timetable');
|
||||
expect(open).toHaveAttribute('target', '_blank');
|
||||
// The host stands beside the title; the whole address would not fit a chip.
|
||||
expect(screen.getByText('ferries.example')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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' } });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
|
||||
import { ExternalLink, Link2, Pencil, Pin, Plus, Trash2, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -18,23 +18,34 @@ interface CollabLink {
|
||||
pinned: boolean | number
|
||||
}
|
||||
|
||||
function LinkIcon({ url, title }: { url: string; title: string }) {
|
||||
function LinkIcon({ url }: { url: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
let favicon = ''
|
||||
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
|
||||
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
|
||||
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
|
||||
return (
|
||||
<span className="collab-link-chip__icon" aria-hidden="true">
|
||||
{!favicon || failed ? <Link2 size={13} /> : <img src={favicon} alt="" onError={() => setFailed(true)} />}
|
||||
<span className="collab-link-chip__go"><ExternalLink size={13} /></span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The part of the address worth a chip's width: the host, without a leading www. */
|
||||
function hostOf(url: string): string {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
||||
}
|
||||
|
||||
/**
|
||||
* The add form as a modal, like the poll and note panels next to it. Inline it
|
||||
* pushed the list down and gave the panel a second header, which is what made
|
||||
* this tab read as a different app from its four siblings.
|
||||
* The add and edit form as a modal, like the poll and note panels next to it.
|
||||
* Inline it pushed the list down and gave the panel a second header, which is
|
||||
* what made this tab read as a different app from its four siblings. With a
|
||||
* link handed in it edits that link (#2414): a link used to be delete-and-add
|
||||
* once its title or address needed a correction.
|
||||
*/
|
||||
function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (link: { title: string; url: string }) => Promise<void> }) {
|
||||
function LinkModal({ link, onClose, onSave }: { link?: CollabLink; onClose: () => void; onSave: (link: { title: string; url: string }) => Promise<void> }) {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [title, setTitle] = useState(link?.title ?? '')
|
||||
const [url, setUrl] = useState(link?.url ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const valid = title.trim().length > 0 && url.trim().length > 0
|
||||
|
||||
@@ -43,10 +54,10 @@ function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (l
|
||||
if (!valid || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onCreate({ title: title.trim(), url: url.trim() })
|
||||
await onSave({ title: title.trim(), url: url.trim() })
|
||||
onClose()
|
||||
} catch {
|
||||
// add() already said what went wrong; the form stays open with the input in it.
|
||||
// The caller already said what went wrong; the form stays open with the input in it.
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -66,7 +77,7 @@ function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (l
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={submit}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.links.add')}</h3>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t(link ? 'collab.links.edit' : 'collab.links.add')}</h3>
|
||||
<button type="button" onClick={onClose} aria-label={t('collab.links.cancel')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
|
||||
</div>
|
||||
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
@@ -99,6 +110,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
const toast = useToast()
|
||||
const [links, setLinks] = useState<CollabLink[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editing, setEditing] = useState<CollabLink | 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.
|
||||
@@ -168,6 +180,17 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
const edit = async (link: CollabLink, data: { title: string; url: string }) => {
|
||||
try {
|
||||
const d = await collabApi.updateLink(tripId, link.id, data)
|
||||
setLinks(v => v.map(x => x.id === link.id ? d.link : x))
|
||||
} catch (err) {
|
||||
console.error('Failed to edit collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await collabApi.deleteLink(tripId, id)
|
||||
@@ -197,36 +220,44 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
|
||||
{/* Content: one chip per link, two to a row (see .collab-link-chip). The chip is
|
||||
the link, with the host beside the title; the actions sit in its tail. */}
|
||||
<div className="chat-scroll collab-link-grid" style={{ flex: 1, overflowY: 'auto', padding: '2px 12px 12px' }}>
|
||||
{links.length === 0 ? (
|
||||
<EmptyState scene="links" title={t('collab.links.empty')} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="collab-link-grid__cells">
|
||||
{links.map(link => (
|
||||
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<LinkIcon url={link.url} title={link.title} />
|
||||
<a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}>
|
||||
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div>
|
||||
<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} />
|
||||
<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>
|
||||
</span>
|
||||
</a>
|
||||
<ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />
|
||||
{canEdit && <>
|
||||
<button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}>
|
||||
<Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}>
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</>}
|
||||
{canEdit && (
|
||||
<span className="collab-link-chip__actions">
|
||||
<button type="button" className="collab-link-chip__action" onClick={() => setEditing(link)} aria-label={t('collab.links.edit')} title={t('collab.links.edit')}>
|
||||
<Pencil size={13} aria-hidden="true" />
|
||||
</button>
|
||||
<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')}>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showForm && <AddLinkModal onClose={() => setShowForm(false)} onCreate={add} />}
|
||||
{/* 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)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1028,4 +1028,21 @@ describe('EntryEditor', () => {
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
it('FE-JRN-EDITOR-056: a clip without a poster is a play badge, not a request for its thumbnail (#2341)', () => {
|
||||
// The thumbnail route answers 404 for such a clip on purpose, and the old
|
||||
// fallback to /original would have handed an <img> the video file itself.
|
||||
const clip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: null }
|
||||
const { container } = mountEditor(buildEntry({ id: 10, photos: [clip] }))
|
||||
|
||||
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-057: a clip with its poster shows the poster like any photo', () => {
|
||||
const clip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }
|
||||
const { container } = mountEditor(buildEntry({ id: 10, photos: [clip] }))
|
||||
|
||||
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg.lucide-play')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
|
||||
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { isVideoFile } from '../../utils/videoPoster'
|
||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||
@@ -11,7 +11,7 @@ import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { photoUrl, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { photoUrl, posterlessVideo, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import MarkdownToolbar from './MarkdownToolbar'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
@@ -527,7 +527,15 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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' : ''}`}>
|
||||
<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 }} />
|
||||
{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>
|
||||
) : (
|
||||
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
)}
|
||||
{idx === 0 && photos.length > 1 && (
|
||||
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { journeyApi, addonsApi, memoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -151,9 +151,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
onClick={() => onPhotoClick(allPhotos, i)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }}
|
||||
>
|
||||
{photo.media_type === 'video' &&
|
||||
photo.provider === 'local' &&
|
||||
!photo.thumbnail_path ? (
|
||||
{posterlessVideo(photo) ? (
|
||||
// Poster-less local video: show a neutral tile.
|
||||
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { PhotoImg } from './JourneyDetailPagePhotoGrid'
|
||||
import type { JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
function buildPhoto(overrides: Partial<JourneyPhoto> = {}): JourneyPhoto {
|
||||
return {
|
||||
id: 1,
|
||||
entry_id: 10,
|
||||
photo_id: 515,
|
||||
sort_order: 0,
|
||||
shared: 1,
|
||||
created_at: 0,
|
||||
provider: 'local',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('PhotoImg', () => {
|
||||
it('asks for the thumbnail of a photo', () => {
|
||||
const { container } = render(<PhotoImg photo={buildPhoto()} />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
|
||||
})
|
||||
|
||||
it('shows the poster of a clip behind the play badge', () => {
|
||||
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', thumbnail_path: 'journey/poster.jpg' })} />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('draws no image for a clip without a poster, whose thumbnail route answers 404 (#2341)', () => {
|
||||
// The tile stays black with the play badge on it. An <img> here would only
|
||||
// add the broken-image glyph the reporter saw.
|
||||
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', thumbnail_path: null })} />)
|
||||
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
expect(container.firstElementChild?.className).toContain('bg-black')
|
||||
})
|
||||
|
||||
it('still asks the server for a provider clip, which serves its own poster', () => {
|
||||
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', provider: 'immich', thumbnail_path: null })} />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image, Play } from 'lucide-react'
|
||||
import type { JourneyPhoto } from '../../store/journeyStore'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
|
||||
export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) {
|
||||
const src = photoUrl(photo, 'thumbnail')
|
||||
@@ -11,12 +11,16 @@ export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; cla
|
||||
className={`relative overflow-hidden ${isVideo ? 'bg-black' : ''} ${className || ''}`}
|
||||
style={style}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* A clip without a poster keeps the black tile and the play badge; asking
|
||||
for its thumbnail would only draw the broken-image glyph over them. */}
|
||||
{!posterlessVideo(photo) && (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isVideo && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
|
||||
@@ -27,6 +27,10 @@ vi.mock('leaflet', () => {
|
||||
setView: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
panTo: vi.fn(),
|
||||
// Runs the callback at once on a map with a view, else on its load event.
|
||||
// The default is the loaded map; the two "no view yet" tests below hold
|
||||
// the callback back and release it by hand.
|
||||
whenReady: vi.fn((cb: () => void) => { cb(); }),
|
||||
getZoom: vi.fn(() => 10),
|
||||
// Leaflet throws "Set map center and zoom first." out of these until the map
|
||||
// has a view; the loaded map is the default here, FE-COMP-JOURNEYMAP-050
|
||||
@@ -334,13 +338,21 @@ describe('JourneyMap', () => {
|
||||
expect(mockedMap().flyTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-018: focusMarker swallows leaflet errors when the map has no view yet', () => {
|
||||
it('FE-COMP-JOURNEYMAP-018: focusMarker waits for the first view when the map has none yet', () => {
|
||||
const ref = React.createRef<JourneyMapHandle>();
|
||||
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
|
||||
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
|
||||
// Leaflet does not refuse a pan on a viewless map, it takes it as the first
|
||||
// view with the zoom undefined, and the tile layer then aborts the load event
|
||||
// before the markers are added. So the pan has to wait for the fit.
|
||||
let onLoad: (() => void) | null = null;
|
||||
vi.mocked(mockedMap().whenReady).mockImplementationOnce((cb: () => void) => { onLoad = cb; });
|
||||
|
||||
expect(() => act(() => { ref.current!.focusMarker('e1'); })).not.toThrow();
|
||||
expect(mockedMap().flyTo).not.toHaveBeenCalled();
|
||||
act(() => { ref.current!.focusMarker('e1'); });
|
||||
expect(mockedMap().panTo).not.toHaveBeenCalled();
|
||||
|
||||
act(() => { onLoad!(); });
|
||||
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
|
||||
expect(mockedMap().setView).not.toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-019: invalidateSize forwards to the leaflet map', () => {
|
||||
@@ -482,14 +494,22 @@ describe('JourneyMap', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-029: activeMarkerId falls back to setView while the map has no view yet', () => {
|
||||
it('FE-COMP-JOURNEYMAP-029: activeMarkerId waits for the first view while the map has none yet', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e1" />);
|
||||
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
|
||||
// The 50 ms timer can beat the rAF that sets the first view, in a
|
||||
// background tab most of all. A pan at that point used to become the first
|
||||
// view itself, with no zoom, and left the map without its markers; the next
|
||||
// rebuild then crashed the page in Leaflet's icon removal.
|
||||
let onLoad: (() => void) | null = null;
|
||||
vi.mocked(mockedMap().whenReady).mockImplementationOnce((cb: () => void) => { onLoad = cb; });
|
||||
act(() => { vi.advanceTimersByTime(60); });
|
||||
// The catch is where a map with no view at all gets its first one.
|
||||
expect(mockedMap().setView).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
|
||||
expect(mockedMap().panTo).not.toHaveBeenCalled();
|
||||
expect(mockedMap().setView).not.toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
|
||||
|
||||
act(() => { onLoad!(); });
|
||||
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
|
||||
expect(mockedMap().flyTo).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -161,6 +161,27 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
|
||||
</div>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Pan, don't zoom: the initial fitBounds decides how far out the reader starts,
|
||||
* and a focus keeps that (discussion #2299).
|
||||
*
|
||||
* Deferred until the map has its first view. That view is set on a rAF after
|
||||
* the build, and the active entry asks for its pan on a 50 ms timer, so a tab
|
||||
* in the background or a slow frame lets the pan come first. Leaflet does not
|
||||
* refuse a pan on a viewless map, it takes it as the first view, with the zoom
|
||||
* still undefined: the tile layer aborts its own add on that, the load event
|
||||
* stops halfway, and every marker queued behind it never makes it onto the map.
|
||||
* The next rebuild then tears down markers that were never added and dies in
|
||||
* Leaflet's icon removal, which took the whole journey page with it after each
|
||||
* save. whenReady runs the pan at once on a map with a view and otherwise right
|
||||
* after the fit lands.
|
||||
*/
|
||||
function panToMarker(map: L.Map, marker: L.Marker): void {
|
||||
map.whenReady(() => {
|
||||
map.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
|
||||
})
|
||||
}
|
||||
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
const EMPTY_TRACKS: JourneyTrack[] = []
|
||||
/** Fallback when a track carries no colour of its own, matching the planner's default. */
|
||||
@@ -254,11 +275,7 @@ function JourneyMap(
|
||||
const focusMarker = useCallback((id: string) => {
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (marker && mapRef.current) {
|
||||
try {
|
||||
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
|
||||
} catch { /* map not yet initialized */ }
|
||||
}
|
||||
if (marker && mapRef.current) panToMarker(mapRef.current, marker)
|
||||
}, [])
|
||||
|
||||
const invalidateSize = useCallback(() => {
|
||||
@@ -511,14 +528,7 @@ function JourneyMap(
|
||||
highlightMarker(activeMarkerId)
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (!marker || !mapRef.current) return
|
||||
// Pan, don't zoom — see focusMarker. fitBounds may still be pending when this
|
||||
// fires, and panTo on a map with no view throws "Set map center and zoom
|
||||
// first", so the catch is where the map gets its first one.
|
||||
try {
|
||||
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
|
||||
} catch {
|
||||
mapRef.current.setView(marker.getLatLng(), 12)
|
||||
}
|
||||
panToMarker(mapRef.current, marker)
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}, [activeMarkerId])
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const plyrCtor = vi.fn()
|
||||
vi.mock('plyr', () => ({
|
||||
default: class {
|
||||
constructor(el: HTMLElement, options: Record<string, unknown>) {
|
||||
plyrCtor(el, options)
|
||||
}
|
||||
destroy = vi.fn()
|
||||
},
|
||||
}))
|
||||
vi.mock('plyr/dist/plyr.css', () => ({}))
|
||||
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import VideoPlayer from './VideoPlayer'
|
||||
|
||||
describe('VideoPlayer', () => {
|
||||
beforeEach(() => { plyrCtor.mockClear() })
|
||||
|
||||
it('hands Plyr the sprite from this origin, not the one on cdn.plyr.io (#2341)', () => {
|
||||
// connect-src refuses cdn.plyr.io, so with the default the player worked and
|
||||
// every button in it was blank. The sprite ships with the bundle instead.
|
||||
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
|
||||
|
||||
expect(plyrCtor).toHaveBeenCalledTimes(1)
|
||||
const [, options] = plyrCtor.mock.calls[0]
|
||||
// A hashed asset URL in the bundle, the raw file (with its query) under vitest.
|
||||
expect(String(options.iconUrl)).toMatch(/plyr[^/]*\.svg(\?|$)/)
|
||||
expect(String(options.iconUrl)).not.toContain('cdn.plyr.io')
|
||||
})
|
||||
|
||||
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.
|
||||
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
|
||||
|
||||
const [, options] = plyrCtor.mock.calls[0]
|
||||
expect(options.blankVideo).toBe('')
|
||||
})
|
||||
|
||||
it('mounts the player on the video element it renders', () => {
|
||||
const { container } = render(<VideoPlayer src="/api/photos/7/original" poster="/api/photos/7/thumbnail" />)
|
||||
|
||||
const [el] = plyrCtor.mock.calls[0]
|
||||
expect(el).toBe(container.querySelector('video'))
|
||||
expect(container.querySelector('video')).toHaveAttribute('poster', '/api/photos/7/thumbnail')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import Plyr from 'plyr'
|
||||
import 'plyr/dist/plyr.css'
|
||||
// The control icons, served from this origin. Plyr's default points at cdn.plyr.io,
|
||||
// which connect-src refuses: the player worked but every button in it was blank.
|
||||
// A file, not a data URL, because Plyr tells same-origin from cross-origin by the
|
||||
// host and would fetch a data URL over XHR, which connect-src refuses as well.
|
||||
import plyrSprite from 'plyr/dist/plyr.svg?no-inline'
|
||||
|
||||
/**
|
||||
* Video player for gallery/lightbox playback (#823), built on Plyr over a native
|
||||
@@ -40,6 +45,12 @@ export default function VideoPlayer({
|
||||
autoplay: autoPlay,
|
||||
clickToPlay: true,
|
||||
hideControls: false,
|
||||
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.
|
||||
blankVideo: '',
|
||||
})
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -102,6 +102,46 @@ describe('buildDayRouteRuns', () => {
|
||||
expect(runs.flat().some(p => p.lat === 48.80)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-009: the stop a booked night wrote is no waypoint; the hotel is the bookend, behind the flight (#2430)', () => {
|
||||
// Check-in at four, a flight in the morning, nothing else: the day plan hides the
|
||||
// hotel's own stop and shows the flight, then the hotel. The road used to start at
|
||||
// the hotel and drive to the departure airport, because the untimed stop kept
|
||||
// its stored place ahead of the timed booking.
|
||||
const hotel = { id: 30, trip_id: 1, place_lat: 53.5465, place_lng: 9.9727, start_day_id: 1, end_day_id: 3, check_in: '16:00', check_out: '11:00' } as unknown as Accommodation
|
||||
const flight = {
|
||||
id: 7, trip_id: 1, type: 'flight', title: 'KL 1783', status: 'confirmed', day_id: 1, end_day_id: 1,
|
||||
reservation_time: '2026-10-19T10:00', reservation_end_time: '2026-10-19T11:00',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'AMS', lat: 52.3105, lng: 4.7683 },
|
||||
{ role: 'to', sequence: 1, name: 'HAM', lat: 53.6304, lng: 9.9882 },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
days: [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 }), buildDay({ id: 3, day_number: 3 })],
|
||||
assignments: { '1': [at(53.5465, 9.9727, 0, { accommodation_id: 30 })] },
|
||||
reservations: [flight],
|
||||
accommodations: [hotel],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
// One road: from the arrival airport to the hotel. None out of the hotel.
|
||||
expect(runs).toHaveLength(1)
|
||||
expect(runs[0].map(p => [p.lat, p.lng])).toEqual([[53.6304, 9.9882], [53.5465, 9.9727]])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-010: a hotel the traveller placed as a stop of their own still ends the road (#2430)', () => {
|
||||
// The same day, but the hotel stop is not the booking's: it stays a waypoint, and
|
||||
// the bookend rule declines the zero-kilometre leg onto itself as before.
|
||||
const hotel = { id: 30, trip_id: 1, place_lat: 53.5465, place_lng: 9.9727, start_day_id: 1, end_day_id: 3, check_in: '16:00', check_out: '11:00' } as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
days: [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 3, day_number: 3 })],
|
||||
assignments: { '1': [at(53.5503, 9.9937, 0), at(53.5465, 9.9727, 1)] },
|
||||
accommodations: [hotel],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
expect(runs).toHaveLength(1)
|
||||
expect(runs[0].map(p => p.lat)).toEqual([53.5503, 53.5465])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
|
||||
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
|
||||
@@ -34,7 +34,18 @@ export interface DayRouteInputs {
|
||||
*/
|
||||
export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRoutePoint[][] {
|
||||
const { days: allDays, assignments, reservations: allReservations, accommodations, optimizeFromAccommodation } = input
|
||||
const da = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
// Without the stop a booked night wrote. The day plan hides that stop (the list
|
||||
// and `useTripPlanner` both filter it) and draws the hotel as the day's bookends
|
||||
// instead, so the road has to be built from the same stops the list shows. Left
|
||||
// in, the stop sat first on a day that opened with a flight, since it carries no
|
||||
// time of its own and a timed booking is seated behind the last timed stop: the
|
||||
// map then drove from the hotel to the airport the traveller had not landed at
|
||||
// yet, and back after the flight (#2430). Road trip mode keeps the stop; it plans
|
||||
// its own drive and never comes through here.
|
||||
const da = (assignments[String(dayId)] || [])
|
||||
.filter(a => a.accommodation_id == null)
|
||||
.slice()
|
||||
.sort((a, b) => a.order_index - b.order_index)
|
||||
const dayOrder = (id: number | null | undefined): number | null => {
|
||||
if (id == null) return null
|
||||
const d = allDays.find(x => x.id === id)
|
||||
|
||||
@@ -253,6 +253,24 @@ describe('downloadTripPDF', () => {
|
||||
expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-013f: the stop a booked night wrote is not listed, so a morning flight prints before the hotel (#2434)', async () => {
|
||||
// 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 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({
|
||||
...richArgs,
|
||||
assignments: { '10': [hotelStop, { ...assignmentForDay, order_index: 1 }] } as any,
|
||||
reservations: [flight],
|
||||
})
|
||||
const html = getIframe()!.srcdoc
|
||||
expect(html).not.toContain('Seewartenstr. 9')
|
||||
expect(html.indexOf('Morning flight')).toBeLessThan(html.indexOf('Colosseum'))
|
||||
expect(html.indexOf('Morning flight')).toBeGreaterThan(-1)
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-013c: a flight that lands the same day shows both times (#1310)', async () => {
|
||||
const sameDay = { ...transportReservation, reservation_end_time: '2025-06-01T16:45:00' }
|
||||
await downloadTripPDF({ ...richArgs, reservations: [sameDay] })
|
||||
|
||||
@@ -335,7 +335,13 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
}
|
||||
// Build day HTML
|
||||
const daysHtml = sorted.map((day, di) => {
|
||||
const assigned = (assignments[String(day.id)] || []).slice()
|
||||
// Without the stop a booked night wrote onto its check-in day: the day plan hides
|
||||
// it (the day already carries the booking as its accommodation block) and the
|
||||
// export lists what the plan lists. The desktop toolbar hands the export the
|
||||
// store's own assignments, filter and all, and the stop sits at the head of its
|
||||
// day, so the hotel printed above a morning flight (#2434).
|
||||
const assigned = (assignments[String(day.id)] || [])
|
||||
.filter((a: any) => a.accommodation_id == null)
|
||||
.sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id).slice()
|
||||
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
|
||||
@@ -223,6 +223,17 @@ describe('DayPlanSidebar', () => {
|
||||
expect(document.body).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-001b: the panel yields to whatever the desktop shell puts above it', () => {
|
||||
// With the Days / Road trip switch above it, a panel sized by height alone ran the
|
||||
// switch's height past the clipped edge and the last day could never be scrolled
|
||||
// into view. jsdom lays nothing out, so the sizing itself is the assertion.
|
||||
const { container } = render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
const panel = container.firstElementChild as HTMLElement
|
||||
expect(panel.style.flex).toBe('1 1 0%')
|
||||
expect(panel.style.minHeight).toBe('0px')
|
||||
expect(panel.style.height).toBe('100%')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-002: renders day titles', () => {
|
||||
const day = buildDay({ title: 'Amsterdam Day', date: '2025-06-01' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
|
||||
@@ -1525,7 +1525,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setPanel} data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
|
||||
// Sized as a flex child as well as by height: the desktop panel puts the Days / Road
|
||||
// trip switch above this, and at height 100% alone the list ran the switch's height
|
||||
// past the panel's clipped edge, so the last day could never be scrolled into view.
|
||||
// Where nothing sits above it (the mobile shell), the height still fills the panel.
|
||||
<div ref={setPanel} data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', flex: '1 1 0%', minHeight: 0, height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
|
||||
{/* Toolbar */}
|
||||
<DayPlanSidebarToolbar
|
||||
tripId={tripId}
|
||||
|
||||
@@ -186,6 +186,53 @@ describe('PlaceFormModal', () => {
|
||||
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-018b: a list the index answered offers Google instead, and the link sends the same query there alone', 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');
|
||||
const searchRow = searchInput.closest('.flex') as HTMLElement;
|
||||
await user.click(within(searchRow).getByRole('button'));
|
||||
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
|
||||
|
||||
// The quiet line under the list, only because the instance has a key and
|
||||
// this list did not come from Google.
|
||||
const retry = screen.getByRole('button', { name: /Search Google instead/ });
|
||||
await user.click(retry);
|
||||
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
|
||||
expect(bodies[0]).not.toHaveProperty('provider');
|
||||
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
|
||||
// A list Google produced has nowhere further to go.
|
||||
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-018c: without a Google key the list offers nothing', async () => {
|
||||
const user = userEvent.setup();
|
||||
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-019: pressing Enter in search input triggers search', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
|
||||
@@ -13,12 +13,12 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import CollectionPicker from '../Collections/CollectionPicker'
|
||||
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus } from 'lucide-react'
|
||||
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw } from 'lucide-react'
|
||||
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 { sourceLabelFor } from '../../utils/placeSource'
|
||||
import { offersGoogleRetry, sourceLabelFor } from '../../utils/placeSource'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -396,7 +396,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleMapsSearch = async () => {
|
||||
const handleMapsSearch = async (provider?: 'google') => {
|
||||
if (!mapsSearch.trim()) return
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
@@ -419,7 +419,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
|
||||
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint, provider)
|
||||
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
|
||||
setMapsResults(result.places || [])
|
||||
setSearchSource(result.source || '')
|
||||
@@ -1002,6 +1002,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 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) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMapsSearch('google')}
|
||||
disabled={isSearchingMaps}
|
||||
className="mt-1.5 inline-flex items-center gap-1 text-caption text-content-faint hover:text-content transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw size={11} strokeWidth={2} aria-hidden="true" />
|
||||
{t('places.searchGoogleInstead')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-COMP-PLACES-001 to FE-COMP-PLACES-015 + FE-PLANNER-SIDEBAR-016 to 043
|
||||
import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render';
|
||||
import { render, screen, fireEvent, waitFor, act, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -50,6 +50,12 @@ beforeEach(() => {
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
});
|
||||
|
||||
/** The filter is one select now: open it, then pick the option by its label. */
|
||||
async function pickFilter(user: ReturnType<typeof userEvent.setup>, label: string) {
|
||||
await user.click(within(screen.getByTestId('places-filter')).getByRole('button'));
|
||||
await user.click(await screen.findByRole('button', { name: new RegExp(`^${label}`) }));
|
||||
}
|
||||
|
||||
describe('PlacesSidebar', () => {
|
||||
it('FE-COMP-PLACES-001: renders without crashing', () => {
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
@@ -251,7 +257,7 @@ describe('Filter tabs', () => {
|
||||
const unplanned = buildPlace({ name: 'Unplanned Place' });
|
||||
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
||||
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
||||
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
||||
await pickFilter(user, 'Unplanned');
|
||||
expect(screen.queryByText('Planned Place')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
|
||||
});
|
||||
@@ -262,8 +268,8 @@ describe('Filter tabs', () => {
|
||||
const unplanned = buildPlace({ name: 'Unplanned Place' });
|
||||
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
||||
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
||||
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^All/i }));
|
||||
await pickFilter(user, 'Unplanned');
|
||||
await pickFilter(user, 'All');
|
||||
expect(screen.getByText('Planned Place')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
|
||||
});
|
||||
@@ -273,7 +279,7 @@ describe('Filter tabs', () => {
|
||||
const place = buildPlace({ name: 'Assigned Place' });
|
||||
const assignments = { '1': [buildAssignment({ place, day_id: 1 })] };
|
||||
render(<PlacesSidebar {...defaultProps} places={[place]} assignments={assignments} />);
|
||||
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
||||
await pickFilter(user, 'Unplanned');
|
||||
expect(screen.getByText(/All places are planned/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -515,7 +521,7 @@ describe('Category filter dropdown', () => {
|
||||
const unplanned = buildPlace({ name: 'Unplanned Place' });
|
||||
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
||||
const { unmount } = render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
||||
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
||||
await pickFilter(user, 'Unplanned');
|
||||
expect(screen.queryByText('Planned Place')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus, CalendarDays } from 'lucide-react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useElementSize } from '../../hooks/useElementSize'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
|
||||
@@ -177,40 +178,17 @@ export function PlacesHeader(S: SidebarState) {
|
||||
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
|
||||
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'planned' | 'tracks'; label: string }>
|
||||
return (
|
||||
// The group always spans the rail and never wraps onto a second line.
|
||||
// Each tab keeps its own text width and only the leftover space is
|
||||
// shared out, so "Unplanned" stays wider than "All" instead of the four
|
||||
// being forced to one size.
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'nowrap' }}>
|
||||
{tabs.map(f => {
|
||||
const active = filter === f.id
|
||||
return (
|
||||
<button type="button"
|
||||
key={f.id}
|
||||
onClick={() => { setFilter(f.id); setSelectedIds(new Set()) }}
|
||||
className={active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: '1 1 auto', minWidth: 0, overflow: 'hidden',
|
||||
padding: compact ? '4px 5px' : '4px 9px', borderRadius: 99,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, whiteSpace: 'nowrap',
|
||||
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
|
||||
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.label}</span>
|
||||
<span className={active ? 'text-accent-text' : 'text-content-faint'} style={{
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, lineHeight: 1,
|
||||
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
|
||||
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{counts[f.id]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
// One select where four pills used to fight for the rail's width: with the
|
||||
// tracks tab the row had grown past what a narrow panel can hold without
|
||||
// clipping a word. The count rides on the trigger the way it rode on the
|
||||
// active pill, and every option carries its own in the menu.
|
||||
<div data-testid="places-filter" style={{ marginBottom: 8 }}>
|
||||
<CustomSelect
|
||||
size="sm"
|
||||
value={filter}
|
||||
onChange={v => { setFilter(v as 'all' | 'unplanned' | 'planned' | 'tracks'); setSelectedIds(new Set()) }}
|
||||
options={tabs.map(f => ({ value: f.id, label: f.label, badge: String(counts[f.id]) }))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
|
||||
|
||||
describe('convertHoursLine', () => {
|
||||
it('FE-PLANNER-HOURS-001: a range that stays in one half of the day carries the meridiem back to its opener', () => {
|
||||
// Google writes "1:00 – 3:00 PM" for one to three in the afternoon (#2412). The
|
||||
// opener used to stay "1:00" next to a converted "15:00".
|
||||
expect(convertHoursLine('Wednesday: 1:00 – 3:00 PM, 5:00 – 8:00 PM', '24h'))
|
||||
.toBe('Wednesday: 13:00 – 15:00, 17:00 – 20:00')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-HOURS-002: a range spelled on both sides is converted as written', () => {
|
||||
expect(convertHoursLine('Monday: 11:00 AM – 1:00 PM', '24h')).toBe('Monday: 11:00 – 13:00')
|
||||
expect(convertHoursLine('Friday: 5:00 PM – 2:00 AM', '24h')).toBe('Friday: 17:00 – 02:00')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-HOURS-003: twelve stays twelve in the afternoon and turns to zero after midnight', () => {
|
||||
expect(convertHoursLine('Sunday: 12:00 – 3:00 PM', '24h')).toBe('Sunday: 12:00 – 15:00')
|
||||
expect(convertHoursLine('Sunday: 12:00 – 1:00 AM', '24h')).toBe('Sunday: 00:00 – 01:00')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-HOURS-004: a morning range keeps its morning', () => {
|
||||
expect(convertHoursLine('Tuesday: 8:00 – 11:30 AM', '24h')).toBe('Tuesday: 08:00 – 11:30')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-HOURS-005: a plain hyphen separates the same way the en dash does', () => {
|
||||
expect(convertHoursLine('Thursday: 1:00-3:00 PM', '24h')).toBe('Thursday: 13:00-15:00')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-HOURS-006: the 12h display and lines without a meridiem are untouched', () => {
|
||||
expect(convertHoursLine('Wednesday: 1:00 – 3:00 PM', '12h')).toBe('Wednesday: 1:00 – 3:00 PM')
|
||||
expect(convertHoursLine('Montag: 11:30–23:00', '24h')).toBe('Montag: 11:30–23:00')
|
||||
expect(convertHoursLine('Saturday: Open 24 hours', '24h')).toBe('Saturday: Open 24 hours')
|
||||
expect(convertHoursLine('', '24h')).toBe('')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-HOURS-007: 24h lines become 12h, with the German "Uhr" dropped', () => {
|
||||
expect(convertHoursLine('Montag: 09:00 – 21:00 Uhr', '12h')).toBe('Montag: 9:00 AM – 9:00 PM')
|
||||
expect(convertHoursLine('Monday: 00:30 – 12:00', '12h')).toBe('Monday: 12:30 AM – 12:00 PM')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitHoursLine', () => {
|
||||
it('FE-PLANNER-HOURS-008: splits on the first colon only, the fullwidth one included', () => {
|
||||
expect(splitHoursLine('Monday: 09:00-18:00')).toEqual(['Monday', '09:00-18:00'])
|
||||
expect(splitHoursLine('月曜日:9:00~18:00')).toEqual(['月曜日', '9:00~18:00'])
|
||||
expect(splitHoursLine('Open 24 hours')).toEqual(['', 'Open 24 hours'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUnknownHoursLine', () => {
|
||||
it('FE-PLANNER-HOURS-009: a bare question mark is the tag saying nothing', () => {
|
||||
expect(isUnknownHoursLine(' ? ')).toBe(true)
|
||||
expect(isUnknownHoursLine('09:00-18:00')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -31,8 +31,18 @@ export function convertHoursLine(line: string, timeFormat: string): string {
|
||||
})
|
||||
}
|
||||
if (timeFormat !== '12h' && hasAmPm) {
|
||||
// Google spells a range that stays in one half of the day with the meridiem on
|
||||
// the closing time only: "1:00 – 3:00 PM" is one to three in the afternoon. The
|
||||
// conversion below only sees times that carry their own AM/PM, so the opener
|
||||
// stayed "1:00" next to a "15:00" (#2412). Hand the closing meridiem back to an
|
||||
// opener that has none. It fires only where nothing but the dash sits between
|
||||
// the two times, so "11:00 AM – 1:00 PM" keeps both halves as written.
|
||||
const paired = line.replace(
|
||||
/(\d{1,2}:\d{2})(\s*[–—-]\s*)(\d{1,2}:\d{2}\s*)(AM|PM)\b/gi,
|
||||
(_, open, sep, close, p) => `${open} ${p}${sep}${close}${p}`,
|
||||
)
|
||||
// 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00"
|
||||
return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => {
|
||||
return paired.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => {
|
||||
let hour = Number.parseInt(h)
|
||||
if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12
|
||||
if (p.toUpperCase() === 'AM' && hour === 12) hour = 0
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRou
|
||||
import type { DryPoint } from './roadtripModel'
|
||||
import type { RefuelSearch } from './useRefuelSearch'
|
||||
import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion'
|
||||
import type { RouteSegment } from '../../types'
|
||||
import type { Reservation, RouteSegment } from '../../types'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
@@ -1087,3 +1087,164 @@ describe('RoadtripModeSwitch', () => {
|
||||
expect(onChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripSidebar with a ride (#2428)', () => {
|
||||
const terminal = (role: 'departure' | 'arrival') =>
|
||||
stop({
|
||||
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
|
||||
name: role === 'departure' ? 'Hamburg Airport' : 'Munich Airport',
|
||||
placeId: -70,
|
||||
lat: role === 'departure' ? 53.63 : 48.35,
|
||||
lng: role === 'departure' ? 9.99 : 11.78,
|
||||
time: role === 'departure' ? '12:20' : '14:30',
|
||||
dwellMinutes: role === 'departure' ? 60 : 0,
|
||||
legMode: role === 'departure' ? 'flight' : null,
|
||||
incomingLegMode: role === 'arrival' ? 'flight' : null,
|
||||
carrier: {
|
||||
reservationId: 70,
|
||||
type: 'flight',
|
||||
role,
|
||||
title: 'LH 2020',
|
||||
code: role === 'departure' ? 'HAM' : 'MUC',
|
||||
at: role === 'departure' ? '13:20' : '14:30',
|
||||
},
|
||||
})
|
||||
const flightDay = () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
terminal('departure'),
|
||||
terminal('arrival'),
|
||||
stop({ assignmentId: 2, name: 'Munich' }),
|
||||
]
|
||||
return day({
|
||||
stops,
|
||||
legs: [leg(), leg({ mode: 'flight', distance: 0, duration: 4200, distanceText: '', durationText: '1 h 10 min' }), leg()],
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '12:20', departure: '13:20', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '14:30', departure: '14:30', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '15:30', departure: '15:30', anchored: false, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes over both terminals with their timetable, and no number', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} />)
|
||||
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
expect(screen.getByText('Departure 13:20')).toBeInTheDocument()
|
||||
expect(screen.getByText('Arrival 14:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument()
|
||||
// Hamburg is 1 and Munich is 2: the terminals between them take no number, and
|
||||
// the day header counts two stops.
|
||||
expect(screen.queryByText('3')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-050: the ride block opens the booking; the road out of the arrival offers no other ways', () => {
|
||||
const onOpenBooking = vi.fn()
|
||||
const onAskAlternatives = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={onAskAlternatives} />)
|
||||
// One control for the whole ride, not one per terminal and one for the band.
|
||||
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
|
||||
fireEvent.click(screen.getByText('Hamburg Airport'))
|
||||
fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min'))
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(2)
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(70)
|
||||
// One shuffle: the road into the departure terminal. Not the ride, not the road out
|
||||
// of the arrival, whose via would be filed at the terminal's index.
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-051: a ride landing tomorrow leaves its departure as a row of its own, with the road into it', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), terminal('departure')]
|
||||
const overnight = day({
|
||||
stops,
|
||||
legs: [leg()],
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '18:00', departure: '18:00', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '21:00', departure: '22:00', anchored: true, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
})
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [overnight], totalStops: 1 })} onOpenBooking={vi.fn()} onAskAlternatives={vi.fn()} />)
|
||||
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
|
||||
expect(screen.getByText('Departure 13:20')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LH 2020 · 1 h 10 min')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () => {
|
||||
const desk = (role: 'pickup' | 'return') =>
|
||||
stop({
|
||||
assignmentId: role === 'pickup' ? -3000000180 : -3000000181,
|
||||
name: role === 'pickup' ? 'Sixt Hauptbahnhof' : 'Sixt Airport',
|
||||
placeId: -90,
|
||||
time: role === 'pickup' ? '09:00' : '11:30',
|
||||
dwellMinutes: 0,
|
||||
carrier: { reservationId: 90, type: 'car', role, title: 'Sixt Hamburg', code: role === 'return' ? 'HAM' : null, at: role === 'pickup' ? '09:00' : '11:30' },
|
||||
})
|
||||
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 stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
|
||||
const rental = day({
|
||||
stops,
|
||||
legs: [leg(), leg(), leg()],
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '09:20', departure: '09:50', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '10:50', departure: '11:20', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '11:30', departure: '11:30', anchored: true, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
})
|
||||
const onOpenBooking = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [rental], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
|
||||
expect(screen.getByText('Pick-up 09:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('Return 11:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
expect(screen.queryByText('3')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Sixt Airport'))
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(90)
|
||||
// Hamburg to Lübeck and Lübeck to the desk leave stored stops; the road out of the
|
||||
// pick-up desk does not.
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(2)
|
||||
})
|
||||
|
||||
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 = [
|
||||
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
|
||||
booking({ id: 12, title: 'Café Niederegger', type: 'restaurant', reservation_time: '2026-10-05T11:30', place_id: 20 }),
|
||||
booking({ id: 13, title: 'Elbphilharmonie', type: 'event', reservation_time: '2026-10-05T20:00', place_id: 999 }),
|
||||
booking({ id: 14, title: 'Tomorrow', type: 'event', day_id: 2, place_id: 10 }),
|
||||
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} />)
|
||||
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
|
||||
expect(screen.getByText('19:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
|
||||
expect(screen.getByText('Also booked this day')).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')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Hotel Hafen')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Tisch Bullerei'))
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(11)
|
||||
// The chips are the stop's, not stops: the count stays at two.
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ChargingInfo from './ChargingInfo'
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
CarFront, Footprints, Bike, Zap, AlertTriangle,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone,
|
||||
@@ -17,7 +17,7 @@ import { formatDistance } from '../../utils/units'
|
||||
import { formatDate, formatClockTime } from '../../utils/formatters'
|
||||
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel'
|
||||
import { STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import { legReroutable } from './roadtripRowModel'
|
||||
import { destinationCount, isHop, legReroutable } from './roadtripRowModel'
|
||||
import { spurWorthLabelling } from './accessSpur'
|
||||
import StopKindPicker from './StopKindPicker'
|
||||
import StopFillPicker from './StopFillPicker'
|
||||
@@ -31,6 +31,9 @@ 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 { bookingClock, bookingIcon, dayBookings } from './stopBookings'
|
||||
import type { Reservation } from '../../types'
|
||||
import type { StayDraft } from './RoadtripStayModal'
|
||||
import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading'
|
||||
|
||||
@@ -102,6 +105,16 @@ interface RoadtripSidebarProps {
|
||||
collapsedDayIds?: Set<number>
|
||||
/** Absent leaves every card open and its header unclickable. */
|
||||
onToggleDay?: (dayId: number) => void
|
||||
/**
|
||||
* The trip's bookings, for the rail to hang under the stops they are for (#2428).
|
||||
* Absent draws the chain without them.
|
||||
*/
|
||||
reservations?: Reservation[]
|
||||
/**
|
||||
* Opens a booking: the ride a terminal stands for, the hire car a desk belongs to,
|
||||
* the table or the tickets under a stop. Absent leaves them all as plain text.
|
||||
*/
|
||||
onOpenBooking?: (reservationId: number) => void
|
||||
}
|
||||
|
||||
const MODE_ICON: Record<string, LucideIcon> = {
|
||||
@@ -680,6 +693,18 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const mode = leg?.mode ?? 'driving'
|
||||
const Icon = mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront
|
||||
// A hop (the hire desk beside the terminal) keeps the line and drops the pill: there is
|
||||
// nothing to say about it and no other way to drive it.
|
||||
if (isHop(leg)) {
|
||||
return (
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<span className="relative z-[1] flex min-h-[10px] flex-col items-center" aria-hidden>
|
||||
<span className="flex-1" style={RAIL_DASH} />
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// The band's contents, shared by the clickable and the read-only shape so the two can
|
||||
// never drift apart in what they say.
|
||||
const band = (
|
||||
@@ -748,6 +773,209 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One end of a ride: the airport, station or port the drive stops at or resumes from.
|
||||
*
|
||||
* No number, no stay, no kind picker and no drag handle: it is not a stop anybody chose
|
||||
* and cannot be moved or turned into anything, it is where the booking puts the
|
||||
* traveller. What it shows is the booking's own clock under its name, and on the right
|
||||
* 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 }: {
|
||||
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)
|
||||
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}
|
||||
>
|
||||
<span className="flex flex-col items-center">
|
||||
{starts ? null : <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden />}
|
||||
<span className={`${DISC} my-1 bg-surface-tertiary text-content-secondary`}>
|
||||
<Icon size={13} strokeWidth={2} aria-hidden />
|
||||
</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>
|
||||
</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.
|
||||
*/
|
||||
function RideEnd({ stop, entry, late }: {
|
||||
stop: RoadtripStop
|
||||
entry: ScheduleEntry | undefined
|
||||
late: ScheduleWarning[]
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
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 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 ? (
|
||||
<span className="shrink-0 font-geist font-medium text-content-faint" style={{ fontSize: FS.meta }}>{stop.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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A ride that leaves and lands on the same day, as one thing in the chain: the booking
|
||||
* at the top, the terminal it leaves from, the minutes in the air, the terminal it lands
|
||||
* at. One block on one disc rather than three rows, because it IS one thing, a flight,
|
||||
* and three rows read as three places the day went to. The whole block opens the
|
||||
* booking; the road resumes below it as usual.
|
||||
*/
|
||||
function RideBlock({ departure, arrival, entries, late, seg, continues, starts, onOpen }: {
|
||||
departure: RoadtripStop
|
||||
arrival: RoadtripStop
|
||||
entries: [ScheduleEntry | undefined, ScheduleEntry | undefined]
|
||||
late: [ScheduleWarning[], ScheduleWarning[]]
|
||||
seg: RouteSegment | undefined
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const carrier = departure.carrier!
|
||||
const Icon = carrierIcon(carrier.type)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
disabled={!onOpen}
|
||||
aria-label={onOpen ? t('roadtrip.ride.open') : 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}
|
||||
>
|
||||
<span className="flex flex-col items-center">
|
||||
{starts ? null : <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden />}
|
||||
<span className={`${DISC} my-1 bg-surface-tertiary text-content-secondary`}>
|
||||
<Icon size={13} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
|
||||
</span>
|
||||
<span className="my-0.5 flex min-w-0 flex-col gap-1 rounded-lg border border-edge-faint bg-surface-tertiary px-2 pb-1.5 pt-1 transition-colors group-hover:bg-surface-selected">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-content-muted">
|
||||
<Icon size={11} strokeWidth={1.8} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate font-geist font-medium uppercase tracking-[0.09em]" style={{ fontSize: FS.label }}>
|
||||
{rideText(carrier, seg)}
|
||||
</span>
|
||||
</span>
|
||||
<RideEnd stop={departure} entry={entries[0]} late={late[0]} />
|
||||
<RideEnd stop={arrival} entry={entries[1]} late={late[1]} />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bookings a stop carries, under its row: 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, not a stop of its own: the count, the numbering
|
||||
* and the drive are none the different for it. Each opens its booking.
|
||||
*
|
||||
* 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 }: {
|
||||
bookings: Reservation[]
|
||||
continues: boolean
|
||||
onOpen?: (reservationId: number) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
return (
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<span className="flex flex-col items-center" aria-hidden>
|
||||
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" /> : null}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-wrap gap-1 px-1.5 pb-1.5">
|
||||
{bookings.map(r => {
|
||||
const Icon = bookingIcon(r.type)
|
||||
const clock = bookingClock(r)
|
||||
const body = (
|
||||
<>
|
||||
<Icon size={10} strokeWidth={2} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate">{r.title}</span>
|
||||
{clock ? <span className="shrink-0 font-medium tabular-nums text-content-faint">{formatClockTime(clock, is12h)}</span> : null}
|
||||
</>
|
||||
)
|
||||
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 ? (
|
||||
<Tooltip key={r.id} label={t('roadtrip.ride.open')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(r.id)}
|
||||
className={`${chip} transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span key={r.id} className={chip} style={{ fontSize: FS.meta }}>{body}</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A charger, a filling station or a rest stop — a stop that interrupts the drive.
|
||||
*
|
||||
@@ -1289,7 +1517,7 @@ function SpillBlock({ spill, children }: {
|
||||
* move, a stay edit or a refuel offer still names the day the server knows it by. See
|
||||
* `nightSpill.ts`.
|
||||
*/
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
|
||||
onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
|
||||
day: RoadtripDay
|
||||
/** Folded down to the header, and off the map with it. */
|
||||
@@ -1303,6 +1531,8 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
drag: DragState
|
||||
onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives']
|
||||
openAlternatives?: RoadtripSidebarProps['openAlternatives']
|
||||
onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
|
||||
reservations?: Reservation[]
|
||||
onEditStay?: RoadtripSidebarProps['onEditStay']
|
||||
onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
|
||||
onSetStopFill?: RoadtripSidebarProps['onSetStopFill']
|
||||
@@ -1319,6 +1549,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
// 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 [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
|
||||
const { t, language } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
@@ -1341,7 +1572,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
* `stop.ownerIndex` instead, because a stop driven onto this date through the night is
|
||||
* still stored on the day it set off from (`nightSpill.ts`).
|
||||
*/
|
||||
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement => {
|
||||
const latenessAt = (i: number): ScheduleWarning[] =>
|
||||
day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
|
||||
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} />
|
||||
@@ -1356,12 +1589,60 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
)) : null}
|
||||
</li>
|
||||
)
|
||||
const service = isServiceStopType(stop.stopType)
|
||||
if (!service) counted += 1
|
||||
// Every finding at this index is read on its own. Taking the first match let an
|
||||
// overnight crossing swallow the "you arrive late" flag without a trace, and a stop
|
||||
// can be late for the time it is reached and the time it is left at both at once.
|
||||
const lateness = day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
|
||||
const lateness = latenessAt(i)
|
||||
// A terminal or a hire car's desk: not draggable, not numbered, not a place. A ride
|
||||
// that lands on the day it left is one block from its departure to its arrival, and
|
||||
// the arrival index draws nothing of its own. A lone terminal (a ride landing
|
||||
// tomorrow, a desk) is a row. Behind either the road goes on as usual, without other
|
||||
// ways to drive it (`legReroutable`).
|
||||
if (stop.carrier) {
|
||||
const prev = day.stops[i - 1]
|
||||
const next = day.stops[i + 1]
|
||||
const sameRide = (other: RoadtripStop | undefined): boolean => other?.carrier?.reservationId === stop.carrier!.reservationId
|
||||
if (stop.carrier.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return null
|
||||
const ride = stop.carrier.role === 'departure' && next?.carrier?.role === 'arrival' && sameRide(next)
|
||||
const open = onOpenBooking ? () => onOpenBooking(stop.carrier!.reservationId) : undefined
|
||||
// The index the road leaves from: the arrival's when the block holds both ends.
|
||||
const tail = ride ? i + 1 : i
|
||||
const after = day.stops[tail + 1]
|
||||
return (
|
||||
<li key={stop.assignmentId}>
|
||||
{ride ? (
|
||||
<RideBlock
|
||||
departure={stop}
|
||||
arrival={next!}
|
||||
entries={[day.schedule.entries[i], day.schedule.entries[i + 1]]}
|
||||
late={[lateness, latenessAt(i + 1)]}
|
||||
seg={day.legs[i]}
|
||||
continues={tail < last}
|
||||
starts={i === 0}
|
||||
onOpen={open}
|
||||
/>
|
||||
) : (
|
||||
<TerminalStop
|
||||
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]} />
|
||||
) : null}
|
||||
{tail < last ? (day.legVias[tail] ?? []).map((via, vi) => (
|
||||
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
|
||||
)) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
const service = isServiceStopType(stop.stopType)
|
||||
if (!service) counted += 1
|
||||
const ownDay = stop.ownerDayId
|
||||
const ownIndex = stop.ownerIndex
|
||||
return (
|
||||
@@ -1426,6 +1707,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
|
||||
/>
|
||||
)}
|
||||
{bookings.atStop.has(i) ? (
|
||||
<BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} onOpen={onOpenBooking} />
|
||||
) : null}
|
||||
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
|
||||
<DriveBand
|
||||
leg={day.legs[i]}
|
||||
@@ -1553,12 +1837,16 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
{formatDate(day.date, language)}
|
||||
</time>
|
||||
) : null}
|
||||
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.leg.driveText', {
|
||||
distance: formatDistance(day.distance / 1000, distanceUnit),
|
||||
time: formatDurationShort(day.duration),
|
||||
})}
|
||||
</span>
|
||||
{/* A day that is only the night booked for it has no drive to sum up and no
|
||||
stop to count; the hotel row below says everything it has to say. */}
|
||||
{day.legs.length > 0 ? (
|
||||
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.leg.driveText', {
|
||||
distance: formatDistance(day.distance / 1000, distanceUnit),
|
||||
time: formatDurationShort(day.duration),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
{day.dayWarning ? (
|
||||
<Tooltip label={t('roadtrip.limit.hint')}>
|
||||
<span className={`${DAY_BADGE} gap-1 bg-warning-soft text-warning`} style={{ fontSize: FS.label }}>
|
||||
@@ -1569,9 +1857,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })}
|
||||
</span>
|
||||
{day.legs.length > 0 ? (
|
||||
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.day.stopCount', { count: destinationCount(day) })}
|
||||
</span>
|
||||
) : null}
|
||||
{/* The other half of "where possible". The setting is a weighting, so a day
|
||||
with no untolled crossing comes back on the toll road — and the only thing
|
||||
worse than not avoiding it is not avoiding it silently, which reads as the
|
||||
@@ -1631,6 +1921,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
</React.Fragment>
|
||||
)
|
||||
))}
|
||||
{/* Booked on this day, for no stop the day drives to: the rail has nowhere
|
||||
better to put them and leaving them out would make the mode look like it
|
||||
lost them. */}
|
||||
{bookings.loose.length ? (
|
||||
<li className="mt-1.5">
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<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} />
|
||||
</li>
|
||||
) : null}
|
||||
</ol>
|
||||
{picking ? (
|
||||
<StopKindPicker
|
||||
@@ -1731,7 +2033,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
|
||||
* them is a pill just for being a number.
|
||||
*/
|
||||
export default function RoadtripSidebar({
|
||||
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
|
||||
routes, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
|
||||
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
|
||||
collapsedDayIds, onToggleDay, onFocusPoint,
|
||||
}: RoadtripSidebarProps): React.ReactElement {
|
||||
@@ -1789,6 +2091,8 @@ export default function RoadtripSidebar({
|
||||
drag={drag}
|
||||
onAskAlternatives={onAskAlternatives}
|
||||
openAlternatives={openAlternatives}
|
||||
onOpenBooking={onOpenBooking}
|
||||
reservations={reservations}
|
||||
onEditStay={onEditStay}
|
||||
onSetStopKind={onSetStopKind}
|
||||
onSetStopFill={onSetStopFill}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
/**
|
||||
* How a ride and its two terminals, and a hire car's two desks, read on the rail and in
|
||||
* the phone chain (#2428).
|
||||
*
|
||||
* 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.
|
||||
* The icons are the booking panel's for the same types, so a flight looks like a flight
|
||||
* wherever the trip shows it.
|
||||
*/
|
||||
const CARRIER_ICON: Record<string, LucideIcon> = {
|
||||
flight: Plane,
|
||||
train: Train,
|
||||
ferry: Sailboat,
|
||||
cruise: Ship,
|
||||
bus: Bus,
|
||||
car: Car,
|
||||
}
|
||||
|
||||
export function carrierIcon(type: string): LucideIcon {
|
||||
return CARRIER_ICON[type] ?? Plane
|
||||
}
|
||||
|
||||
/**
|
||||
* The line under a terminal's name: the timetable's clock at it, in the reader's own
|
||||
* clock format. Nothing when the booking names no time.
|
||||
*/
|
||||
export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h: boolean): string | null {
|
||||
if (!carrier.at) return null
|
||||
const time = formatClockTime(carrier.at, is12h)
|
||||
return t(`roadtrip.ride.${carrier.role}`, { time })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function rideText(carrier: CarrierTerminal, seg: RouteSegment | undefined): string {
|
||||
const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
|
||||
const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '')
|
||||
return duration ? `${carrier.title} · ${duration}` : carrier.title
|
||||
}
|
||||
@@ -520,3 +520,88 @@ describe('legReroutable', () => {
|
||||
expect(legReroutable(extra, -1)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roadtripRows with a ride (#2428)', () => {
|
||||
const terminal = (name: string, role: 'departure' | 'arrival', at: string | null = '13:20') =>
|
||||
stop(name, {
|
||||
assignmentId: role === 'departure' ? -3000000014 : -3000000015,
|
||||
placeId: -7,
|
||||
carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: role === 'departure' ? 'MUC' : 'HAM', at },
|
||||
legMode: role === 'departure' ? 'flight' : null,
|
||||
incomingLegMode: role === 'arrival' ? 'flight' : null,
|
||||
})
|
||||
const ride: RouteSegment = { ...seg(9), distance: 0, duration: 4200, mode: 'flight', distanceText: '', durationText: '1 h 10 min' }
|
||||
const flightDay = () =>
|
||||
day([stop('Bremen'), terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30'), stop('Hotel')], {
|
||||
legs: [seg(0), ride, seg(2)],
|
||||
})
|
||||
|
||||
it('FE-RTROW-045: a same-day ride is one row holding both terminals, unnumbered, and the numbering skips them', () => {
|
||||
const rows = roadtripRows(flightDay())
|
||||
expect(stopRows(rows).map(r => [r.stop.name, r.number])).toEqual([
|
||||
['Bremen', 1],
|
||||
['Hotel', 2],
|
||||
])
|
||||
const rideRow = rows.find(r => r.kind === 'ride')
|
||||
expect(rideRow?.kind === 'ride' && [rideRow.departure.stop.name, rideRow.departure.number, rideRow.arrival.stop.name, rideRow.arrival.number])
|
||||
.toEqual(['Munich Airport', null, 'Hamburg Airport', null])
|
||||
expect(destinationCount(flightDay())).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-RTROW-046: the ride row stands where the departure stood and carries the booking; the road out of the arrival follows it as a leg', () => {
|
||||
const rows = roadtripRows(flightDay())
|
||||
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'ride', 'leg', 'stop'])
|
||||
const rideRow = rows[2]
|
||||
expect(rideRow.kind === 'ride' && rideRow.carrier.title).toBe('LH 2020')
|
||||
expect(rideRow.kind === 'ride' && rideRow.seg).toBe(ride)
|
||||
expect(rideRow.kind === 'ride' && rideRow.arrival.time).toBe(rideRow.kind === 'ride' ? rideRow.arrival.entry?.arrival ?? null : null)
|
||||
// The leg after the ride is the arrival's, at the arrival's index.
|
||||
expect(rows[3]).toMatchObject({ kind: 'leg', index: 2 })
|
||||
})
|
||||
|
||||
it('FE-RTROW-049: a ride landing tomorrow leaves its departure as a lone stop row, and the arrival opens the next day as one', () => {
|
||||
const rows = roadtripRows(day([stop('Bremen'), terminal('Munich Airport', 'departure')], { legs: [seg(0)] }))
|
||||
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
|
||||
const landing = roadtripRows(day([terminal('Hamburg Airport', 'arrival', '07:00'), stop('Hotel')], { legs: [seg(0)] }))
|
||||
expect(landing.map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
|
||||
expect(stopRows(landing).map(r => [r.stop.name, r.number])).toEqual([['Hamburg Airport', null], ['Hotel', 1]])
|
||||
})
|
||||
|
||||
it('FE-RTROW-051: a hop, the desk beside the terminal, is no leg row and is offered no other ways', () => {
|
||||
const hop: RouteSegment = { ...seg(0), distance: 120, duration: 45, distanceText: '0 km', durationText: '0 min' }
|
||||
const d = day([stop('Sixt Airport'), stop('Hamburg Airport'), stop('Hotel')], { legs: [hop, seg(1)] })
|
||||
expect(roadtripRows(d).map(r => r.kind)).toEqual(['stop', 'stop', 'leg', 'stop'])
|
||||
expect(legReroutable(d, 0)).toBe(false)
|
||||
expect(legReroutable(d, 1)).toBe(true)
|
||||
// A note a plugin attached keeps the row, however short the hop.
|
||||
const noted = day([stop('A'), stop('B')], { legs: [{ ...hop, noteText: '10 min charge' }] })
|
||||
expect(roadtripRows(noted).map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
|
||||
})
|
||||
|
||||
it('FE-RTROW-050: a hire car\'s desks are unnumbered stop rows the road runs through', () => {
|
||||
const desk = (name: string, role: 'pickup' | 'return') =>
|
||||
stop(name, {
|
||||
assignmentId: role === 'pickup' ? -3000000018 : -3000000019,
|
||||
placeId: -9,
|
||||
carrier: { reservationId: 9, type: 'car', role, title: 'Sixt', code: null, at: '09:00' },
|
||||
})
|
||||
const rows = roadtripRows(day([desk('Sixt Hbf', 'pickup'), stop('Bremen'), desk('Sixt Airport', 'return')], { legs: [seg(0), seg(1)] }))
|
||||
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop'])
|
||||
expect(stopRows(rows).map(r => [r.stop.name, r.number])).toEqual([['Sixt Hbf', null], ['Bremen', 1], ['Sixt Airport', null]])
|
||||
const d = day([desk('Sixt Hbf', 'pickup'), stop('Bremen'), desk('Sixt Airport', 'return')], { legs: [seg(0), seg(1)] })
|
||||
expect(legReroutable(d, 0)).toBe(false)
|
||||
expect(legReroutable(d, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-RTROW-047: the road into a departure terminal can be offered other ways, nothing leaving a terminal can', () => {
|
||||
const d = flightDay()
|
||||
expect(legReroutable(d, 0)).toBe(true)
|
||||
expect(legReroutable(d, 1)).toBe(false)
|
||||
expect(legReroutable(d, 2)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-RTROW-048: a terminal never answers for a place, however its id reads', () => {
|
||||
expect(firstStopOfPlace([flightDay()], -7)).toBeNull()
|
||||
expect(firstStopOfPlace([flightDay()], 100)?.name).toBe('Bremen')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
|
||||
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { CarrierTerminal, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import { readStay } from './stayReading'
|
||||
|
||||
/**
|
||||
@@ -21,13 +21,19 @@ export type RoadtripRow =
|
||||
| { kind: 'spill'; fromDayNumber: number; departs: string | null; stops: StopRow[] }
|
||||
| StopRow
|
||||
| { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null }
|
||||
/**
|
||||
* A ride that leaves and lands on the day (#2428), as one row: the booking with both
|
||||
* its terminals inside it, in place of two stop rows and the leg between them. The
|
||||
* booking's minutes, no distance, and the road goes on from `arrival`.
|
||||
*/
|
||||
| { kind: 'ride'; index: number; carrier: CarrierTerminal; seg: RouteSegment | undefined; departure: StopRow; arrival: StopRow }
|
||||
| { kind: 'dry'; legIndex: number; intoLegKm: number; sinceKm: number }
|
||||
| { kind: 'auto'; phase: 'end' | 'resume'; time: string | null }
|
||||
|
||||
export interface StopRow {
|
||||
kind: 'stop'
|
||||
stop: RoadtripStop
|
||||
/** Position in the day's numbering, or null for a service stop and an automatic night. */
|
||||
/** Position in the day's numbering, or null for a service stop, a terminal and an automatic night. */
|
||||
number: number | null
|
||||
service: boolean
|
||||
entry: ScheduleEntry | undefined
|
||||
@@ -132,6 +138,20 @@ function isStationary(seg: RouteSegment | undefined): boolean {
|
||||
&& seg.from[1] === seg.to[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* A leg too short to be a drive: the hire desk beside the terminal, the car park beside
|
||||
* the gate. Under 150 m and two minutes it is a hop, and a pill reading "0 km in 0 min"
|
||||
* under it is noise where the eye wants the next stop. The line still joins the two,
|
||||
* and a note a plugin attached is still worth the row.
|
||||
*/
|
||||
export function isHop(seg: RouteSegment | undefined): boolean {
|
||||
return !!seg
|
||||
&& Number.isFinite(seg.distance)
|
||||
&& seg.distance < 150
|
||||
&& seg.duration < 120
|
||||
&& !seg.noteText
|
||||
}
|
||||
|
||||
export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
|
||||
const rows: RoadtripRow[] = []
|
||||
const spills = day.spills ?? []
|
||||
@@ -148,6 +168,13 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
|
||||
})
|
||||
}
|
||||
|
||||
const next = day.stops[i + 1]
|
||||
const prev = day.stops[i - 1]
|
||||
const sameRide = (other: RoadtripStop | undefined): boolean =>
|
||||
!!stop.carrier && other?.carrier?.reservationId === stop.carrier.reservationId
|
||||
// The arrival of a same-day ride is inside the ride row its departure made.
|
||||
if (stop.carrier?.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return
|
||||
|
||||
const automatic = !!stop.automaticNight
|
||||
if (automatic) {
|
||||
rows.push({
|
||||
@@ -155,22 +182,44 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
|
||||
phase: stop.automaticNight?.phase === 'start' ? 'resume' : 'end',
|
||||
time: day.schedule.entries[i]?.arrival ?? null,
|
||||
})
|
||||
} else if (stop.carrier?.role === 'departure' && next?.carrier?.role === 'arrival' && sameRide(next)) {
|
||||
rows.push({
|
||||
kind: 'ride',
|
||||
index: i,
|
||||
carrier: stop.carrier,
|
||||
seg: day.legs[i],
|
||||
departure: stopRow(stop, i, null, day.schedule, day.driveWarnings),
|
||||
arrival: stopRow(next!, i + 1, null, day.schedule, day.driveWarnings),
|
||||
})
|
||||
// The road out of the arrival belongs to this row too; the pass below only
|
||||
// looks at the index it is on.
|
||||
pushLeg(i + 1)
|
||||
return
|
||||
} else {
|
||||
if (!isServiceStopType(stop.stopType)) number += 1
|
||||
rows.push(stopRow(stop, i, isServiceStopType(stop.stopType) ? null : number, day.schedule, day.driveWarnings))
|
||||
// A terminal or a hire car's desk is where the drive stops or resumes, not a
|
||||
// place the trip is for: it carries no number, the way a service stop carries none.
|
||||
const unnumbered = isServiceStopType(stop.stopType) || !!stop.carrier
|
||||
if (!unnumbered) number += 1
|
||||
rows.push(stopRow(stop, i, unnumbered ? null : number, day.schedule, day.driveWarnings))
|
||||
}
|
||||
|
||||
// The leg AFTER this stop, plus the dry point that falls on it. Both belong
|
||||
// between two stops, so they are emitted here rather than in their own pass.
|
||||
pushLeg(i)
|
||||
})
|
||||
|
||||
// The leg AFTER stop `i`, plus the dry point that falls on it. Both belong between
|
||||
// two stops, so they are emitted with the stop before them rather than in a pass of
|
||||
// their own.
|
||||
function pushLeg(i: number): void {
|
||||
const stop = day.stops[i]!
|
||||
const seg = day.legs[i]
|
||||
if (i < day.stops.length - 1 && !isStationary(seg)) {
|
||||
if (i < day.stops.length - 1 && !isStationary(seg) && !isHop(seg)) {
|
||||
rows.push({ kind: 'leg', index: i, seg, mode: day.stops[i + 1]?.incomingLegMode ?? stop.legMode ?? null })
|
||||
const dry = (day.dryPoints ?? []).find(p => p.legIndex === i)
|
||||
if (dry) {
|
||||
rows.push({ kind: 'dry', legIndex: i, intoLegKm: dry.intoLegKm, sinceKm: dry.sinceKm })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -191,13 +240,19 @@ export function legReroutable(day: RoadtripDay, index: number): boolean {
|
||||
return index >= 0
|
||||
&& index < day.stops.length - 1
|
||||
&& !!day.legs[index]
|
||||
&& !isHop(day.legs[index])
|
||||
&& !day.stops[index].automaticNight
|
||||
&& !day.stops[index + 1].automaticNight
|
||||
// A leg leaving a terminal is either the ride, which has no other way, or the road
|
||||
// out of an arrival, whose via would be filed at the terminal's index, which is the
|
||||
// index of the stop after it. The road INTO a departure terminal leaves a stored
|
||||
// stop and can be offered other ways like any other.
|
||||
&& !day.stops[index].carrier
|
||||
}
|
||||
|
||||
/** Stops that carry a number, for a count that agrees with the numbering above. */
|
||||
export function destinationCount(day: RoadtripDay): number {
|
||||
return day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length
|
||||
return day.stops.filter(s => !s.automaticNight && !s.carrier && !isServiceStopType(s.stopType)).length
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,7 +331,7 @@ export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
|
||||
*/
|
||||
export function firstStopOfPlace(days: readonly RoadtripDay[], placeId: number): RoadtripStop | null {
|
||||
for (const day of days) {
|
||||
const stop = day.stops.find(s => !s.automaticNight && s.placeId === placeId)
|
||||
const stop = day.stops.find(s => !s.automaticNight && !s.carrier && s.placeId === placeId)
|
||||
if (stop) return stop
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Car, Ticket, Utensils } from 'lucide-react'
|
||||
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
const booking = (over: Partial<Reservation>): Reservation =>
|
||||
({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation
|
||||
|
||||
const day = {
|
||||
dayId: 1,
|
||||
stops: [
|
||||
{ assignmentId: -3000000140, placeId: -70, carrier: { reservationId: 70, type: 'flight', role: 'arrival' as const, title: 'LH', code: null, at: null } },
|
||||
{ assignmentId: 11, placeId: 110 },
|
||||
{ assignmentId: -2000000001, placeId: 110, automaticNight: { phase: 'end' as const, fromDayNumber: 1 } as never },
|
||||
{ assignmentId: 12, placeId: 120 },
|
||||
],
|
||||
}
|
||||
|
||||
describe('dayBookings (#2428)', () => {
|
||||
it('FE-STOPBOOKINGS-001: a booking hangs under the stop it is pinned to, or under the stop at its place on its day, in clock order', () => {
|
||||
const { atStop, loose } = dayBookings(day, [
|
||||
booking({ id: 3, title: 'Late', reservation_time: '2026-10-05T20:00', assignment_id: 11 }),
|
||||
booking({ id: 2, title: 'Early', reservation_time: '2026-10-05T11:00', place_id: 110 }),
|
||||
booking({ id: 4, title: 'Untimed', place_id: 110 }),
|
||||
booking({ id: 5, title: 'Ticket', type: 'event', place_id: 120 }),
|
||||
])
|
||||
expect([...atStop.keys()]).toEqual([1, 3])
|
||||
expect(atStop.get(1)!.map(r => r.title)).toEqual(['Early', 'Late', 'Untimed'])
|
||||
expect(atStop.get(3)!.map(r => r.title)).toEqual(['Ticket'])
|
||||
expect(loose).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-STOPBOOKINGS-002: a terminal and an automatic night hold nothing, even at the same place; a pinned booking needs no day', () => {
|
||||
// The night marker shares the hotel's place id; the booking still lands on the stop.
|
||||
const { atStop } = dayBookings(day, [booking({ id: 6, place_id: 110, day_id: 1 })])
|
||||
expect([...atStop.keys()]).toEqual([1])
|
||||
// Pinned to the assignment: on the stop whatever day the row names.
|
||||
const pinned = dayBookings(day, [booking({ id: 7, assignment_id: 12, day_id: 9 })])
|
||||
expect([...pinned.atStop.keys()]).toEqual([3])
|
||||
// A booking at the flight's negative place id is nobody's.
|
||||
expect(dayBookings(day, [booking({ id: 8, place_id: -70 })]).loose).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-STOPBOOKINGS-003: the night, the hire car and the ride are drawn elsewhere; another day\'s booking is not this day\'s; a span ends on this day', () => {
|
||||
const { atStop, loose } = dayBookings(day, [
|
||||
booking({ id: 1, type: 'hotel', place_id: 110 }),
|
||||
booking({ id: 2, type: 'car', place_id: 110 }),
|
||||
booking({ id: 3, type: 'flight', place_id: 110 }),
|
||||
booking({ id: 4, type: 'train', place_id: 110 }),
|
||||
booking({ id: 5, title: 'Tomorrow', day_id: 2, place_id: 110 }),
|
||||
booking({ id: 6, title: 'Tour ends here', type: 'tour', day_id: 0, end_day_id: 1 }),
|
||||
])
|
||||
expect(atStop.size).toBe(0)
|
||||
expect(loose.map(r => r.title)).toEqual(['Tour ends here'])
|
||||
expect(dayBookings(day, [])).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)
|
||||
expect(bookingIcon('nonsense')).toBe(Ticket)
|
||||
expect(bookingClock({ reservation_time: '2026-10-05T19:30' })).toBe('19:30')
|
||||
expect(bookingClock({ reservation_time: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Ticket, type LucideIcon } from 'lucide-react'
|
||||
import { isCarrierType, isRentalType } from '@trek/shared/roadtrip'
|
||||
import { RES_ICONS } from '../Planner/DayPlanSidebar.constants'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
import type { Reservation } from '../../types'
|
||||
import type { RoadtripStop } from '@trek/shared/roadtrip'
|
||||
|
||||
/**
|
||||
* The bookings a day carries besides its nights, its rides and its hire car: the table,
|
||||
* the tickets, the tour. On the road trip each hangs under the stop it is for, and one
|
||||
* that is for no stop on the day is listed under the day, so nothing booked goes unseen
|
||||
* in this mode.
|
||||
*
|
||||
* A booking is a stop's when it is pinned to the stop's assignment, or, failing that,
|
||||
* when it is for the stop's place on the stop's day: a table booked at the Bullerei on
|
||||
* the day the Bullerei is driven to is the Bullerei's, pinned or not. The night, the
|
||||
* ride and the hire car are left out because the rail already draws them as the stay,
|
||||
* the terminals and the desks.
|
||||
*/
|
||||
export interface DayBookings {
|
||||
/** By index into `day.stops`, earliest first. Indexes without a booking are absent. */
|
||||
atStop: Map<number, Reservation[]>
|
||||
/** On the day, at no stop of it. */
|
||||
loose: Reservation[]
|
||||
}
|
||||
|
||||
const EMPTY: DayBookings = { atStop: new Map(), loose: [] }
|
||||
|
||||
function drawnElsewhere(type: string): boolean {
|
||||
return type === 'hotel' || isRentalType(type) || isCarrierType(type)
|
||||
}
|
||||
|
||||
/** Earliest first, untimed last, id as the tiebreaker: the day plan's own order. */
|
||||
function byClock(a: Reservation, b: Reservation): number {
|
||||
const at = a.reservation_time || ''
|
||||
const bt = b.reservation_time || ''
|
||||
if (at !== bt) {
|
||||
if (!at) return 1
|
||||
if (!bt) return -1
|
||||
return at < bt ? -1 : 1
|
||||
}
|
||||
return a.id - b.id
|
||||
}
|
||||
|
||||
export function dayBookings(
|
||||
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight'>[] },
|
||||
reservations: readonly Reservation[],
|
||||
): DayBookings {
|
||||
if (!reservations.length) return EMPTY
|
||||
const atStop = new Map<number, Reservation[]>()
|
||||
const loose: Reservation[] = []
|
||||
for (const r of reservations) {
|
||||
if (drawnElsewhere(r.type)) continue
|
||||
const stops = day.stops
|
||||
// Only a real stop can hold a booking: a terminal has a negative place and an
|
||||
// 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)
|
||||
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) ?? []
|
||||
list.push(r)
|
||||
atStop.set(index, list)
|
||||
} else if (onDay) {
|
||||
loose.push(r)
|
||||
}
|
||||
}
|
||||
for (const list of atStop.values()) list.sort(byClock)
|
||||
loose.sort(byClock)
|
||||
return atStop.size || loose.length ? { atStop, loose } : EMPTY
|
||||
}
|
||||
|
||||
/** The booking panel's icon for the type, so a table reads as a table here too. */
|
||||
export function bookingIcon(type: string): LucideIcon {
|
||||
return (RES_ICONS as Record<string, LucideIcon>)[type] ?? Ticket
|
||||
}
|
||||
|
||||
/** The clock the booking starts at, or null when it names none. */
|
||||
export function bookingClock(r: Pick<Reservation, 'reservation_time'>): string | null {
|
||||
return splitReservationDateTime(r.reservation_time).time || null
|
||||
}
|
||||
@@ -261,4 +261,26 @@ describe('useCorridorPois', () => {
|
||||
expect(result.current.failedAreas).toBeGreaterThan(0)
|
||||
expect(pois.mock.calls.length).toBeLessThan(result.current.progress.total + 1)
|
||||
})
|
||||
it('FE-ROADTRIP-CORRIDOR-018: a ride is left out of the search, and a hit under the flight path is dropped (#2428)', async () => {
|
||||
// A long line whose middle is a flight: boxes are asked for the two driven ends only,
|
||||
// and nothing found between the two terminals is on the way.
|
||||
const from = LONG[3]
|
||||
const to = LONG[8]
|
||||
pois.mockResolvedValue(answer(hit('under-the-flight', LONG[5].lat, LONG[5].lng), hit('on-the-road', LONG[1].lat, LONG[1].lng + 0.01)))
|
||||
const { result } = renderHook(() => useCorridorPois(LONG, ['fuel'], 10, { gaps: [{ from, to }] }))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
const asked = pois.mock.calls.map(c => c[1] as { south: number; north: number })
|
||||
// No box covers the middle of the ride.
|
||||
expect(asked.some(b => b.south <= LONG[5].lat && b.north >= LONG[5].lat && b.north - b.south < 0.2)).toBe(false)
|
||||
expect(result.current.results.map(r => r.osm_id)).toEqual(['on-the-road'])
|
||||
|
||||
// The same line without the ride searches straight through.
|
||||
const { result: plain } = renderHook(() => useCorridorPois(LONG, ['fuel'], 10))
|
||||
act(() => { plain.current.search() })
|
||||
await waitFor(() => expect(plain.current.loading).toBe(false))
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,9 +85,22 @@ export function useCorridorPois(
|
||||
* is not a desk and a search that keeps the radio warm for a minute is a search that
|
||||
* costs battery somebody is navigating on.
|
||||
*/
|
||||
options?: { budget?: CorridorBudget },
|
||||
options?: {
|
||||
budget?: CorridorBudget
|
||||
/**
|
||||
* Stretches of the line the car never drives (#2428): a flight, a ferry, a train
|
||||
* between two terminals. The line runs straight from one terminal to the other
|
||||
* 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.
|
||||
*/
|
||||
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 })
|
||||
@@ -139,7 +152,31 @@ export function useCorridorPois(
|
||||
// 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
|
||||
const allTiles = corridorTiles(tileLine, widthKm)
|
||||
// The ridden stretches, as kilometres along the spine, in order.
|
||||
const skipped = (gaps ?? [])
|
||||
.map(gap => {
|
||||
const a = projectOntoRoute(gap.from, spine)?.alongKm
|
||||
const b = projectOntoRoute(gap.to, spine)?.alongKm
|
||||
return a === undefined || b === undefined ? null : { fromKm: Math.min(a, b), toKm: Math.max(a, b) }
|
||||
})
|
||||
.filter((g): g is { fromKm: number; toKm: number } => g !== null && g.toKm > g.fromKm)
|
||||
.sort((a, b) => a.fromKm - b.fromKm)
|
||||
const inGap = (alongKm: number): boolean => skipped.some(g => alongKm > g.fromKm + 0.05 && alongKm < g.toKm - 0.05)
|
||||
// The tile line cut around the gaps: one piece per driven stretch.
|
||||
const pieces: LatLng[][] = []
|
||||
if (skipped.length) {
|
||||
const start = window?.fromKm ?? 0
|
||||
const end = window?.toKm ?? Number.POSITIVE_INFINITY
|
||||
let cursor = start
|
||||
for (const gap of skipped) {
|
||||
if (gap.fromKm > cursor) pieces.push(sliceAtMeters(spine, cursor * 1000, Math.min(gap.fromKm, end) * 1000))
|
||||
cursor = Math.max(cursor, gap.toKm)
|
||||
}
|
||||
if (cursor < end) pieces.push(Number.isFinite(end) ? sliceAtMeters(spine, cursor * 1000, end * 1000) : sliceAtMeters(spine, cursor * 1000, Number.MAX_SAFE_INTEGER))
|
||||
} else {
|
||||
pieces.push(tileLine)
|
||||
}
|
||||
const allTiles = pieces.filter(piece => piece.length > 1).flatMap(piece => corridorTiles(piece, widthKm))
|
||||
const tiles = allTiles.slice(0, budget.maxTiles)
|
||||
const startedAt = Date.now()
|
||||
const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs
|
||||
@@ -184,7 +221,7 @@ export function useCorridorPois(
|
||||
for (const poi of data.pois) {
|
||||
if (seen.has(poi.osm_id)) continue
|
||||
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
|
||||
if (!hit || hit.offRouteKm > widthKm) continue
|
||||
if (!hit || hit.offRouteKm > widthKm || inGap(hit.alongKm)) continue
|
||||
seen.set(poi.osm_id, {
|
||||
...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null,
|
||||
opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null,
|
||||
@@ -251,7 +288,10 @@ export function useCorridorPois(
|
||||
setError(failures === jobs.length && failures > 0)
|
||||
setLoading(false)
|
||||
})()
|
||||
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs])
|
||||
// gapKey stands in for `gaps`: the same terminals mean the same gaps, and a fresh
|
||||
// array every render would make every search a new function.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gapKey])
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
|
||||
@@ -119,7 +119,21 @@ export function useRoadtripCorridor(
|
||||
return day.stops.map(s => ({ lat: s.lat, lng: s.lng }))
|
||||
}, [day])
|
||||
|
||||
const search = useCorridorPois(line, categories, widthKm, options)
|
||||
// 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 search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps }), [options, gaps]))
|
||||
|
||||
/**
|
||||
* Whether what the panel looks for has been decided yet.
|
||||
|
||||
@@ -849,3 +849,122 @@ describe('a visit End on the road trip', () => {
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||
}))
|
||||
|
||||
describe('a booking the traveller rides (#2428)', () => {
|
||||
const MUC: [number, number] = [48.3538, 11.7861]
|
||||
const HAM: [number, number] = [53.6304, 9.9882]
|
||||
const flight = (over: Record<string, unknown> = {}) => ({
|
||||
id: 70,
|
||||
trip_id: 7,
|
||||
title: 'LH 2020 HAM → MUC',
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
day_id: 1,
|
||||
end_day_id: 1,
|
||||
reservation_time: '13:20',
|
||||
reservation_end_time: '14:30',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Hamburg Airport', code: 'HAM', lat: HAM[0], lng: HAM[1], timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'Munich Airport', code: 'MUC', lat: MUC[0], lng: MUC[1], timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
...over,
|
||||
}) as unknown as import('../../types').Reservation
|
||||
|
||||
/** An hour for whatever pair the router is handed. */
|
||||
const hourly = (points: { lat: number; lng: number }[]) => ({
|
||||
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
|
||||
distance: 100000 * (points.length - 1),
|
||||
duration: 3600 * (points.length - 1),
|
||||
legs: points.slice(1).map(() => ({ distance: 100000, duration: 3600, text: '' })),
|
||||
})
|
||||
|
||||
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-040: the terminals seam the day, the ride is never routed, and the roads to and from them are', 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: LUENEBURG, time: '10:00', dwell: 0 },
|
||||
{ id: 3, at: BERLIN, dwell: 0 },
|
||||
]
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [flight()]))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const d = result.current.days[0]
|
||||
expect(d.stops.map(s => s.carrier?.role ?? s.name)).toEqual(['Stop 1', 'Stop 2', 'departure', 'arrival', 'Stop 3'])
|
||||
// Two road runs went to the router: up to the departure terminal, and from the arrival on.
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
const asked = calculateRouteWithLegs.mock.calls.map(c => c[0].map((p: { lat: number }) => p.lat))
|
||||
expect(asked).toEqual([[HAMBURG[0], LUENEBURG[0], HAM[0]], [MUC[0], BERLIN[0]]])
|
||||
expect(d.legs.map(l => l?.mode)).toEqual(['driving', 'driving', 'flight', 'driving'])
|
||||
expect(d.legs[2]).toMatchObject({ distance: 0, duration: 70 * 60 })
|
||||
// An hour ahead of the flight at the airport, off on the timetable, landed on it.
|
||||
expect(d.schedule.entries.map(e => [e.arrival, e.departure])).toEqual([
|
||||
['09:00', '09:00'],
|
||||
['10:00', '10:00'],
|
||||
['12:20', '13:20'],
|
||||
['14:30', '14:30'],
|
||||
['15:30', '15:30'],
|
||||
])
|
||||
// The ride draws no road of its own; the three road legs do.
|
||||
expect(result.current.lines).toHaveLength(3)
|
||||
expect(result.current.totalStops).toBe(3)
|
||||
expect(result.current.totalDistance).toBe(300000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-041: a ride landing on a later day seams two cards, and with connected days the ride is the join', 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 = flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 })
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], [], [overnight]))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
await waitFor(() => expect(result.current.days[1]?.arrivingLeg?.mode).toBe('flight'))
|
||||
expect(result.current.days[0].stops.map(s => s.carrier?.role ?? s.name)).toEqual(['Stop 1', 'departure'])
|
||||
expect(result.current.days[1].stops.map(s => s.carrier?.role ?? s.name)).toEqual(['arrival', 'Stop 2'])
|
||||
expect(result.current.days[1].schedule.entries[0].arrival).toBe('07:00')
|
||||
// The join between the days is the ride, and the router was asked for the two roads only.
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
expect(result.current.days[1].arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600, distance: 0 })
|
||||
})
|
||||
|
||||
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 }]
|
||||
const unlocated = flight({ endpoints: [] })
|
||||
const taxi = flight({ id: 71, type: 'taxi' })
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [unlocated, taxi]))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[0].stops.every(s => !s.carrier)).toBe(true)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-043: a hire car puts its desks on the road, one run through both, and no ride', async () => {
|
||||
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
||||
const stops: StopSpec[] = [{ id: 1, at: LUENEBURG, time: '10:00', dwell: 0 }, { id: 2, at: BERLIN, time: '14:00', dwell: 0 }]
|
||||
const hireCar = flight({
|
||||
id: 71,
|
||||
type: 'car',
|
||||
title: 'Sixt',
|
||||
reservation_time: '09:00',
|
||||
reservation_end_time: '18:00',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Sixt Hamburg', code: null, lat: HAMBURG[0], lng: HAMBURG[1], timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'Sixt Berlin', code: null, lat: BERLIN[0] + 0.01, lng: BERLIN[1], timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
})
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [hireCar]))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const d = result.current.days[0]
|
||||
expect(d.stops.map(s => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 1', 'Stop 2', 'return'])
|
||||
// One road, the whole day, desk to desk.
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
expect(d.legs.map(l => l?.mode)).toEqual(['driving', 'driving', 'driving'])
|
||||
expect(d.schedule.entries[0]).toMatchObject({ arrival: '09:00', departure: '09:00' })
|
||||
expect(result.current.totalStops).toBe(2)
|
||||
expect(result.current.lines).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
|
||||
import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, seatCarrierStops, standsAsDay, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
@@ -7,7 +7,7 @@ import { splitIntoRuns, parseAvoid, type DriveLimits } from './roadtripModel'
|
||||
import { spillChains } from './nightSpill'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
import type { Assignment, AssignmentsMap, Accommodation, Day, RouteAvoidClass, SnappedWaypoint } from '../../types'
|
||||
import type { Assignment, AssignmentsMap, Accommodation, Day, Reservation, RouteAvoidClass, SnappedWaypoint } from '../../types'
|
||||
import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared'
|
||||
import { dayWindow } from './dayWindow'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
@@ -60,9 +60,7 @@ const stopKey = (s: RoadtripStop): string =>
|
||||
* so the two cannot drift and quietly stop refetching.
|
||||
*/
|
||||
const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>): string =>
|
||||
(viasByDay[from.ownerDayId] ?? [])
|
||||
.filter(v => v.after_order_index === from.ownerIndex)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
viasLeaving(from, viasByDay[from.ownerDayId] ?? [])
|
||||
.map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`)
|
||||
.join('|')
|
||||
|
||||
@@ -70,6 +68,7 @@ const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>)
|
||||
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
|
||||
|
||||
const EMPTY_ACCOMMODATIONS: Accommodation[] = []
|
||||
const EMPTY_RESERVATIONS: Reservation[] = []
|
||||
|
||||
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[]): RoadtripStop | null => {
|
||||
const p = a.place
|
||||
@@ -91,6 +90,7 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommoda
|
||||
// traveller's own statement about this stop, and the stay then runs until it.
|
||||
leaveAt: a.assignment_end_time ?? p.end_time ?? null,
|
||||
checkInTime: stay?.check_in ?? null,
|
||||
night: stay !== undefined,
|
||||
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
|
||||
endDay: a.end_day === true,
|
||||
legMode: a.leg_transport_mode ?? null,
|
||||
@@ -126,6 +126,13 @@ export function useRoadtripRoutes(
|
||||
viasByDay: Record<number, RoadtripVia[]> = {},
|
||||
boundaries: RoadtripDayBoundary[] = [],
|
||||
accommodations: Accommodation[] = EMPTY_ACCOMMODATIONS,
|
||||
/**
|
||||
* The trip's bookings, for the ones the traveller rides (#2428). A flight, train,
|
||||
* ferry, cruise or bus with located terminals seams the drive: the road ends at the
|
||||
* terminal it leaves from and starts again at the one it lands at, and the leg
|
||||
* between the two is the ride, never a road.
|
||||
*/
|
||||
reservations: Reservation[] = EMPTY_RESERVATIONS,
|
||||
): RoadtripRoutes {
|
||||
const { t } = useTranslation()
|
||||
const routeProfile = fallbackProfile || 'driving'
|
||||
@@ -201,47 +208,45 @@ export function useRoadtripRoutes(
|
||||
[legMinutes, dayMinutes, planningRangeKm, fillPercent],
|
||||
)
|
||||
|
||||
const plan = useMemo<PlanDay[]>(() => {
|
||||
// The seams the bookings make. Only the rides matter here, and only their days,
|
||||
// clocks, terminals and seats, so a renamed booking or a new note on it changes nothing.
|
||||
const rideSeams = useMemo<CarrierSeam[]>(
|
||||
() => reservations.map(r => carrierSeam(r)).filter((seam): seam is CarrierSeam => seam !== null),
|
||||
[reservations],
|
||||
)
|
||||
|
||||
// Every stored day as the road trip reads it: its located stops in stored order, with
|
||||
// the terminals of its rides seated among them. Built once and split below, because
|
||||
// the two halves are one list read two ways.
|
||||
const storedDays = useMemo<PlanDay[]>(() => {
|
||||
return [...days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(d => {
|
||||
const stops = (assignments[String(d.id)] ?? [])
|
||||
const located = (assignments[String(d.id)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(a => asStop(a, d.id, 0, accommodations))
|
||||
.filter((s): s is RoadtripStop => s !== null)
|
||||
// The index is filled in after the drop, because it is the index into THIS
|
||||
// list: an assignment whose place has no coordinates never becomes a stop, and
|
||||
// counting before the filter would name a row the rail does not draw.
|
||||
.map((s, i) => ({ ...s, ownerIndex: i }))
|
||||
.map(a => ({ assignment: a, stop: asStop(a, d.id, 0, accommodations) }))
|
||||
.filter((x): x is { assignment: Assignment; stop: RoadtripStop } => x.stop !== null)
|
||||
// The index is filled in after the drop, because it is the index into THIS
|
||||
// list: an assignment whose place has no coordinates never becomes a stop, and
|
||||
// counting before the filter would name a row the rail does not draw.
|
||||
const stops = located.map((x, i) => ({ ...x.stop, ownerIndex: i }))
|
||||
return {
|
||||
dayId: d.id,
|
||||
dayNumber: d.day_number ?? 0,
|
||||
date: d.date ?? null,
|
||||
title: d.title ?? null,
|
||||
stops,
|
||||
stops: seatCarrierStops(d.id, stops, located.map((x, i) => x.assignment.order_index ?? i), rideSeams),
|
||||
}
|
||||
})
|
||||
.filter(d => d.stops.length > 1)
|
||||
}, [days, assignments, accommodations])
|
||||
}, [days, assignments, accommodations, rideSeams])
|
||||
|
||||
const quietDays = useMemo<QuietDay[]>(() => {
|
||||
return [...days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(d => ({
|
||||
dayId: d.id,
|
||||
dayNumber: d.day_number ?? 0,
|
||||
date: d.date ?? null,
|
||||
title: d.title ?? null,
|
||||
stops: (assignments[String(d.id)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(a => asStop(a, d.id, 0, accommodations))
|
||||
.filter((s): s is RoadtripStop => s !== null)
|
||||
.map((s, i) => ({ ...s, ownerIndex: i })),
|
||||
}))
|
||||
.filter(d => d.stops.length < 2)
|
||||
}, [days, assignments, accommodations])
|
||||
const plan = useMemo<PlanDay[]>(() => storedDays.filter(d => standsAsDay(d.stops)), [storedDays])
|
||||
|
||||
const quietDays = useMemo<QuietDay[]>(() => storedDays.filter(d => !standsAsDay(d.stops)), [storedDays])
|
||||
|
||||
/** 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
|
||||
|
||||
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
|
||||
// editing its notes must not fire a routing round.
|
||||
@@ -300,6 +305,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) })
|
||||
return
|
||||
}
|
||||
/** The same stops, as one request per pair. A pair has nothing left to split. */
|
||||
const splitIntoPairs = (): void => {
|
||||
if (run.length <= 2) return
|
||||
@@ -322,9 +336,7 @@ export function useRoadtripRoutes(
|
||||
// index — this used to be the position within the chain, so on any day that
|
||||
// received a night drive every via matched nothing and quietly stopped
|
||||
// shaping the road.
|
||||
;(viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
|
||||
.filter(v => v.after_order_index === (stop.ownerIndex ?? i))
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
viasLeaving(stop, viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
|
||||
.forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
|
||||
})
|
||||
|
||||
@@ -527,14 +539,20 @@ export function useRoadtripRoutes(
|
||||
{ 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
|
||||
// shape: it is asked for on its own, so the points the traveller dropped on it
|
||||
// never reached the router and dragging one did visibly nothing.
|
||||
const shaping = (viasByDay[seam.from.ownerDayId] ?? [])
|
||||
.filter(v => v.after_order_index === seam.from.ownerIndex)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
const shaping = viasLeaving(seam.from, viasByDay[seam.from.ownerDayId] ?? [])
|
||||
const r = await calculateRouteWithLegs(
|
||||
[
|
||||
{ lat: seam.from.lat, lng: seam.from.lng },
|
||||
|
||||
@@ -67,9 +67,20 @@
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
/* Below the two-column breakpoint the halves stack and the panel scrolls. */
|
||||
/* 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; overflow-y: auto; }
|
||||
.rn-panel { grid-template-columns: 1fr; grid-template-rows: auto; overflow-y: auto; }
|
||||
.rn-release, .rn-release-inner, .rn-note-body { overflow: visible; }
|
||||
/* 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 {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background: color-mix(in srgb, var(--bg-card) 95%, var(--text-primary));
|
||||
}
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-overlay,
|
||||
@@ -581,6 +592,15 @@
|
||||
|
||||
.rn-note-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
/*
|
||||
* The two columns scroll; what is inside them must not give way instead. A flex
|
||||
* item may shrink below its content, and one with overflow hidden may shrink to
|
||||
* nothing, so on a 1080p screen at 100% the promise box was pressed down to its
|
||||
* label while the paragraphs around it kept their height and the column scrolled.
|
||||
*/
|
||||
.rn-release-inner > *,
|
||||
.rn-note-body > * { flex-shrink: 0; }
|
||||
|
||||
.rn-note-eyebrow {
|
||||
font-family: var(--font-subtext);
|
||||
font-size: 11px;
|
||||
|
||||
@@ -36,4 +36,15 @@ describe('release notice css', () => {
|
||||
expect(step('--z-notice')).toBeGreaterThan(step('--z-nav'))
|
||||
expect(step('--z-notice')).toBeGreaterThan(61)
|
||||
})
|
||||
|
||||
it('FE-COMP-RELEASENOTICECSS-003: what the columns hold keeps its height, the columns scroll', () => {
|
||||
// A flex item with overflow hidden may shrink to nothing. The promise box is
|
||||
// one, and on a 1080p screen at 100% it was pressed down to its label while
|
||||
// the column around it scrolled. jsdom lays nothing out, so the rule itself
|
||||
// is the assertion.
|
||||
const columns = block('.rn-release-inner > *,')
|
||||
expect(columns).toMatch(/\.rn-note-body > \*/)
|
||||
expect(columns).toMatch(/flex-shrink:\s*0/)
|
||||
expect(block('.rn-promise {')).toMatch(/overflow:\s*hidden/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1375,6 +1375,136 @@ img[alt="TREK"] {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Shared links in the Collab tab: one chip per link, two to a row across the full
|
||||
width, one to a row where the panel is narrow. The chip is the link; edit, pin and
|
||||
delete sit quietly in its tail and come forward on hover or keyboard focus. They
|
||||
keep their width throughout: unfolding them reflowed the row under the pointer,
|
||||
and a chip that grows as you reach for it is a chip you cannot catch. */
|
||||
.collab-link-grid {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.collab-link-grid__cells {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
@container (max-width: 420px) {
|
||||
.collab-link-grid__cells { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.collab-link-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 5px 8px 5px 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-faint);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
.collab-link-chip:hover, .collab-link-chip:focus-within {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
.collab-link-chip--pinned {
|
||||
background: var(--accent-subtle);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
.collab-link-chip__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
.collab-link-chip__icon {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.collab-link-chip__icon img { width: 16px; height: 16px; object-fit: contain; }
|
||||
.collab-link-chip__icon > * { transition: opacity 0.15s ease; }
|
||||
/* Reaching for the chip turns its favicon into the open-in-new-tab glyph: the
|
||||
affordance without a glyph of its own taking room from the title. */
|
||||
.collab-link-chip__go {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-primary);
|
||||
opacity: 0;
|
||||
}
|
||||
.collab-link-chip__main:hover .collab-link-chip__go, .collab-link-chip__main:focus-visible .collab-link-chip__go { opacity: 1; }
|
||||
.collab-link-chip__main:hover .collab-link-chip__icon > :not(.collab-link-chip__go),
|
||||
.collab-link-chip__main:focus-visible .collab-link-chip__icon > :not(.collab-link-chip__go) { opacity: 0; }
|
||||
/* Title first, host with what is left: a max-content track takes its full width
|
||||
before the fr track sees any, and shrinks only once the title alone no longer
|
||||
fits. Two flex items shrank together and both ended in an ellipsis. */
|
||||
.collab-link-chip__text {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
column-gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.collab-link-chip__title {
|
||||
font-size: calc(12.5px * var(--fs-scale-body, 1));
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.collab-link-chip__host {
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
color: var(--text-faint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.collab-link-chip__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 1px;
|
||||
margin-left: auto;
|
||||
opacity: 0.45;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.collab-link-chip:hover .collab-link-chip__actions, .collab-link-chip:focus-within .collab-link-chip__actions { opacity: 1; }
|
||||
.collab-link-chip__action {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.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); }
|
||||
@media (hover: none) {
|
||||
.collab-link-chip__actions { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Markdown in Collab Notes */
|
||||
.collab-note-md strong, .collab-note-md-full strong { font-weight: 700 !important; }
|
||||
.collab-note-md em, .collab-note-md-full em { font-style: italic !important; }
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
|
||||
placesDetailsEnabled, setPlacesDetailsEnabledState,
|
||||
placesEnrichEnabled, setPlacesEnrichEnabledState,
|
||||
placesGoogleOnly, handleTogglePlacesGoogleOnly,
|
||||
placeShadowEnabled, setPlaceShadowEnabledState,
|
||||
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
|
||||
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
||||
@@ -350,6 +351,17 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MAdminRow
|
||||
title={t('admin.placesGoogleOnly.title')}
|
||||
hint={t(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')}
|
||||
trailing={
|
||||
<MToggle
|
||||
checked={placesGoogleOnly}
|
||||
ariaLabel={t('admin.placesGoogleOnly.title')}
|
||||
onChange={() => handleTogglePlacesGoogleOnly()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</MBlockDisclosure>
|
||||
</MProviderBlock>
|
||||
|
||||
|
||||
@@ -3,14 +3,16 @@ 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 { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings'
|
||||
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
|
||||
import { formatDistance } from '../../../../utils/units'
|
||||
import { formatClockTime } from '../../../../utils/formatters'
|
||||
import type { StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import type { RoadtripRow, StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch'
|
||||
import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
|
||||
import type { DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
|
||||
import type { TranslationFn } from '../../../../types'
|
||||
import type { Reservation, TranslationFn } from '../../../../types'
|
||||
|
||||
/**
|
||||
* The four row types of the mobile drive chain, plus the two bands that interrupt it.
|
||||
@@ -52,6 +54,16 @@ export interface RowChrome {
|
||||
*/
|
||||
function Disc({ row, t, onPickKind }: { row: StopRow; t: TranslationFn; onPickKind?: () => void }) {
|
||||
const kind = row.stop.stopType ? STOP_KIND_BY_KEY[row.stop.stopType] : undefined
|
||||
// A terminal wears the booking's icon and is not a control: nobody turns an airport
|
||||
// into a petrol station.
|
||||
if (row.stop.carrier) {
|
||||
const Icon = carrierIcon(row.stop.carrier.type)
|
||||
return (
|
||||
<span className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Icon size={15} strokeWidth={2.1} aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const face = row.service && kind
|
||||
? (
|
||||
<span
|
||||
@@ -134,6 +146,8 @@ export function RtStopRow({ row, chrome, onOpen, onPickKind }: {
|
||||
)
|
||||
}
|
||||
if (row.warning) marks.push(<span key="warn">{warningMark(row.warning, chrome)}</span>)
|
||||
const terminal = row.stop.carrier ? terminalLine(row.stop.carrier, t, chrome.is12h) : null
|
||||
if (terminal) marks.push(<span key="terminal" className="font-geist text-[0.65625rem] font-medium text-m-muted">{terminal}</span>)
|
||||
if (!marks.length && row.offRoadMeters) {
|
||||
marks.push(
|
||||
<Mark key="off" icon={<Footprints size={10} strokeWidth={2} />}>
|
||||
@@ -173,6 +187,105 @@ const LEG_ICONS: Record<string, typeof CarFront> = {
|
||||
cycling: Bike,
|
||||
}
|
||||
|
||||
/** One end of a ride inside its block: name, code, the timetable line, the clock. */
|
||||
function RideEnd({ row, chrome }: { row: StopRow; chrome: RowChrome }) {
|
||||
const carrier = row.stop.carrier!
|
||||
const line = terminalLine(carrier, chrome.t, chrome.is12h)
|
||||
return (
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-[6px]">
|
||||
<span className="line-clamp-2 text-[0.875rem] font-semibold leading-[1.25] text-m-ink">{row.stop.name}</span>
|
||||
{carrier.code && <span className="flex-none font-geist text-[0.65625rem] font-medium text-m-faint">{carrier.code}</span>}
|
||||
</span>
|
||||
{line && <span className="mt-[2px] block font-geist text-[0.65625rem] font-medium text-m-muted">{line}</span>}
|
||||
</span>
|
||||
{row.time && (
|
||||
<span dir="ltr" className={`whitespace-nowrap text-[0.8125rem] tabular-nums ${row.pinned ? 'font-semibold text-m-ink' : 'font-medium text-m-faint'}`}>
|
||||
{formatClockTime(row.time, chrome.is12h)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A ride that leaves and lands on the day, as one block in the chain (#2428): the
|
||||
* booking and its minutes at the top, the terminal it leaves from, the terminal it lands
|
||||
* at. One block on one disc, because it is one thing, and three rows read as three
|
||||
* places the day went to. The block is the tap target and opens the booking, the same
|
||||
* sheet the map's endpoint badge opens.
|
||||
*/
|
||||
export function RtRideRow({ row, chrome, onOpen }: {
|
||||
row: Extract<RoadtripRow, { kind: 'ride' }>
|
||||
chrome: RowChrome
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
const Icon = carrierIcon(row.carrier.type)
|
||||
const body = (
|
||||
<>
|
||||
<span className="flex items-center gap-[6px] text-m-muted">
|
||||
<Icon size={12} strokeWidth={2} className="flex-none" aria-hidden="true" />
|
||||
<span className="truncate font-geist text-[0.65625rem] font-semibold uppercase tracking-[0.06em]">{rideText(row.carrier, row.seg)}</span>
|
||||
</span>
|
||||
<RideEnd row={row.departure} chrome={chrome} />
|
||||
<RideEnd row={row.arrival} chrome={chrome} />
|
||||
</>
|
||||
)
|
||||
const cls = 'my-1 flex min-w-0 flex-col gap-[7px] rounded-[16px] bg-[color:var(--m-ic)] px-[12px] py-[9px] text-start'
|
||||
return (
|
||||
<div className="grid items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr' }}>
|
||||
<span className="flex justify-center">
|
||||
<span className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Icon size={15} strokeWidth={2.1} aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
{onOpen ? (
|
||||
<button type="button" onClick={onOpen} className={cls}>{body}</button>
|
||||
) : (
|
||||
<span className={cls}>{body}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function RtBookingChips({ bookings, chrome, onOpen }: {
|
||||
bookings: Reservation[]
|
||||
chrome: RowChrome
|
||||
onOpen?: (reservation: Reservation) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-x-[10px] pb-2" style={{ gridTemplateColumns: '34px 1fr' }}>
|
||||
<span aria-hidden="true" />
|
||||
<span className="flex flex-wrap gap-[6px]">
|
||||
{bookings.map(r => {
|
||||
const Icon = bookingIcon(r.type)
|
||||
const clock = bookingClock(r)
|
||||
const body = (
|
||||
<>
|
||||
<Icon size={11} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate text-[0.75rem] font-medium text-m-ink">{r.title}</span>
|
||||
{clock && <span className="flex-none font-geist text-[0.65625rem] tabular-nums text-m-faint">{formatClockTime(clock, chrome.is12h)}</span>}
|
||||
</>
|
||||
)
|
||||
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 ? (
|
||||
<button key={r.id} type="button" onClick={() => onOpen(r)} className={`${cls} text-start`}>{body}</button>
|
||||
) : (
|
||||
<span key={r.id} className={cls}>{body}</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a leg pill says: how far and how long, or that it is not routed yet.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef } from 'react'
|
||||
import { Fragment, useMemo, useRef } from 'react'
|
||||
import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
|
||||
import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
|
||||
import { showStopOnMap, useMRoadtrip } from './useMRoadtrip'
|
||||
@@ -6,7 +6,7 @@ import { useMRtCorridor } from './useMRtCorridor'
|
||||
import { useMRtAlternatives } from './useMRtAlternatives'
|
||||
import MRtCorridorBar from './MRtCorridorBar'
|
||||
import MRtAlternativesBar from './MRtAlternativesBar'
|
||||
import { RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
|
||||
import { RtAutoRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
|
||||
import MBadge from '../../../components/MBadge'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
|
||||
@@ -17,6 +17,8 @@ import { formatClockTime } from '../../../../utils/formatters'
|
||||
import { isRtlLanguage } from '../../../../i18n'
|
||||
import type { MTripTabPanelProps } from '../MTripShell'
|
||||
import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import { dayBookings } from '../../../../components/Roadtrip/stopBookings'
|
||||
import type { Reservation } from '../../../../types'
|
||||
|
||||
/**
|
||||
* The road trip tab: one day of the drive, as a chain or on the map.
|
||||
@@ -77,7 +79,29 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
const canEditPlaces = planner.can('place_edit', planner.trip)
|
||||
|
||||
const openStop = (row: StopRow) => {
|
||||
shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
|
||||
// A terminal is the booking's, and opens it: there is no stop sheet for an airport.
|
||||
if (row.stop.carrier) shell.openSheet('transport', { reservationId: row.stop.carrier.reservationId })
|
||||
else shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
|
||||
}
|
||||
|
||||
// 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.
|
||||
const bookings = useMemo(() => {
|
||||
const byIndex = stage ? dayBookings(stage, planner.reservations) : null
|
||||
const atStop = new Map<number, Reservation[]>()
|
||||
if (stage && byIndex) for (const [i, list] of byIndex.atStop) atStop.set(stage.stops[i]!.assignmentId, list)
|
||||
return { atStop, loose: byIndex?.loose ?? [] }
|
||||
}, [stage, planner.reservations])
|
||||
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)
|
||||
}
|
||||
|
||||
// The search bar sits in the same band on both halves, at the same offset, so the
|
||||
@@ -224,20 +248,23 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
<section className="mt-2.5 overflow-hidden rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-3 pt-1">
|
||||
{rt.rows.map((row, i) => {
|
||||
if (row.kind === 'stop') {
|
||||
const chips = bookings.atStop.get(row.stop.assignmentId)
|
||||
return (
|
||||
<RtStopRow
|
||||
key={`s${i}`}
|
||||
row={row}
|
||||
chrome={chrome}
|
||||
onOpen={() => openStop(row)}
|
||||
onPickKind={canEditPlaces
|
||||
? () => shell.openSheet('rtkind', {
|
||||
placeId: row.stop.placeId,
|
||||
stopType: row.stop.stopType ?? null,
|
||||
name: row.stop.name,
|
||||
})
|
||||
: undefined}
|
||||
/>
|
||||
<Fragment key={`s${i}`}>
|
||||
<RtStopRow
|
||||
row={row}
|
||||
chrome={chrome}
|
||||
onOpen={() => openStop(row)}
|
||||
onPickKind={canEditPlaces && !row.stop.carrier
|
||||
? () => shell.openSheet('rtkind', {
|
||||
placeId: row.stop.placeId,
|
||||
stopType: row.stop.stopType ?? null,
|
||||
name: row.stop.name,
|
||||
})
|
||||
: undefined}
|
||||
/>
|
||||
{chips && <RtBookingChips bookings={chips} chrome={chrome} onOpen={openBooking} />}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'leg') {
|
||||
@@ -256,6 +283,9 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'ride') {
|
||||
return <RtRideRow key={`r${i}`} row={row} chrome={chrome} onOpen={() => shell.openSheet('transport', { reservationId: row.carrier.reservationId })} />
|
||||
}
|
||||
if (row.kind === 'auto') return <RtAutoRow key={`a${i}`} phase={row.phase} time={row.time} chrome={chrome} />
|
||||
if (row.kind === 'spill') {
|
||||
return <RtSpillRow key={`p${i}`} fromDayNumber={row.fromDayNumber} departs={row.departs} chrome={chrome} />
|
||||
@@ -279,6 +309,15 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{bookings.loose.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<div className="grid gap-x-[10px]" style={{ gridTemplateColumns: '34px 1fr' }}>
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, Search } from 'lucide-react'
|
||||
import { Loader2, RotateCcw, Search } from 'lucide-react'
|
||||
import { mapsApi } from '../../../../api/client'
|
||||
import { sourceLabelFor } from '../../../../utils/placeSource'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
import { offersGoogleRetry, sourceLabelFor } from '../../../../utils/placeSource'
|
||||
import { recordPlacePick } from '../../../../api/placeShadow'
|
||||
import { PlacesSession } from '../../../../utils/placesSession'
|
||||
import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
|
||||
@@ -95,6 +96,9 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
// The name the whole list carries, for rows that do not name their own index.
|
||||
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 setResolving = useCallback(
|
||||
(v: boolean) => {
|
||||
@@ -168,7 +172,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
const handleSearch = async (provider?: 'google') => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return
|
||||
setSuggestions([])
|
||||
@@ -200,9 +204,10 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
}
|
||||
// Derselbe Hinweis, den die Vervollstaendigung schon bekommt: die Suche
|
||||
// braucht ihn genauso, nur als Punkt statt als Kasten.
|
||||
const result = await mapsApi.search(trimmed, language, pointFromBox(locationBias))
|
||||
const result = await mapsApi.search(trimmed, language, pointFromBox(locationBias), provider)
|
||||
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
|
||||
setResults(result.places || [])
|
||||
setSearchSource(result.source || '')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
@@ -284,7 +289,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearch}
|
||||
onClick={() => handleSearch()}
|
||||
disabled={searching}
|
||||
aria-label={t('common.search')}
|
||||
className="flex h-10 w-10 flex-none items-center justify-center rounded-[12px] bg-m-act text-m-actfg disabled:opacity-60"
|
||||
@@ -332,6 +337,20 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 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) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSearch('google')}
|
||||
disabled={searching}
|
||||
className="mt-2 inline-flex items-center gap-1 font-geist text-[0.6875rem] text-m-muted disabled:opacity-60"
|
||||
>
|
||||
<RotateCcw size={11} strokeWidth={2} aria-hidden="true" />
|
||||
{t('places.searchGoogleInstead')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
|
||||
import { BOOKING_TYPE_COLOR } from './bookingsModel'
|
||||
@@ -262,6 +262,8 @@ function BookingCard({ res, planner, canEdit, compact }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
|
||||
|
||||
<MConfirmSheet
|
||||
open={confirmDelete}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import {
|
||||
TRANSPORT_TYPE_COLOR,
|
||||
@@ -280,6 +280,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
|
||||
|
||||
<MConfirmSheet
|
||||
open={confirmDelete}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import type { ReservationTraveler } from '@trek/shared'
|
||||
import { avatarSrc } from '../../../../utils/avatarSrc'
|
||||
import GuestBadge from '../../../../components/shared/GuestBadge'
|
||||
import PluginFrame from '../../../../components/Plugins/PluginFrame'
|
||||
import { usePluginStore } from '../../../../store/pluginStore'
|
||||
|
||||
/**
|
||||
* Scroll body shared by the list-style trip tabs (transports, bookings, costs,
|
||||
@@ -120,6 +122,36 @@ export function StatusDot({ color }: { color: string }) {
|
||||
return <span className="h-[7px] w-[7px] flex-none rounded-full" style={{ background: color }} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins that declared a reservation-detail slot, mounted at the foot of a
|
||||
* transport / booking card and scoped to that reservation: the phone half of
|
||||
* what ReservationsPanel does on the desktop, which the phone cards never had,
|
||||
* so a widget like Flight Tracker only appeared once the viewport crossed the
|
||||
* desktop breakpoint (#2440). Sits outside the card's body button (buttons
|
||||
* cannot nest, and a tap in the frame must not open the detail sheet), and the
|
||||
* frame follows the host colour scheme like the desktop detail slots do, so a
|
||||
* kit plugin that goes dark with TREK keeps compositing transparently.
|
||||
*/
|
||||
export function ReservationPluginSlots({ tripId, reservationId }: { tripId: number; reservationId: number }) {
|
||||
const plugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'reservation-detail')
|
||||
if (plugins.length === 0) return null
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-3 pb-3">
|
||||
{plugins.map(p => (
|
||||
<div key={p.id} className="overflow-hidden rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card">
|
||||
<PluginFrame
|
||||
pluginId={p.id}
|
||||
tripId={String(tripId)}
|
||||
reservationId={String(reservationId)}
|
||||
title={p.name}
|
||||
surface="detail-slot"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only avatar-chip cluster for a reservation's assigned travelers (#1517).
|
||||
* The mobile transport / booking cards only display them — assignment editing
|
||||
|
||||
@@ -25,7 +25,13 @@ vi.mock('../components/Collections/CollectionFilterBar', () => ({
|
||||
? <button type="button" onClick={props.onAddPlace}>collections.addPlace</button>
|
||||
: <div />,
|
||||
}))
|
||||
vi.mock('../components/Collections/CollectionMapPanel', () => ({ default: () => <div data-testid="map" /> }))
|
||||
const mapPanelProps = vi.hoisted(() => ({ current: null as null | { onSelect: (id: number) => void; onDeselect: () => void } }))
|
||||
vi.mock('../components/Collections/CollectionMapPanel', () => ({
|
||||
default: (props: { onSelect: (id: number) => void; onDeselect: () => void }) => {
|
||||
mapPanelProps.current = props
|
||||
return <div data-testid="map" />
|
||||
},
|
||||
}))
|
||||
vi.mock('../components/Collections/CopyToTripModal', () => ({ default: () => null }))
|
||||
vi.mock('../components/Collections/MoveToListModal', () => ({ default: () => null }))
|
||||
vi.mock('../components/Collections/ShareCollectionModal', () => ({ default: () => null }))
|
||||
@@ -147,3 +153,17 @@ describe('CollectionsPage — the map survives a filter that matches nothing', (
|
||||
expect(screen.queryByTestId('map')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CollectionsPage: a marker click shows the place (#2431)', () => {
|
||||
it('selects the place a marker stands for, even when that place is already selected', () => {
|
||||
const setSelectedPlaceId = vi.fn()
|
||||
mockUseCollections.mockReturnValue(makeHook({ selectedPlaceId: 10, setSelectedPlaceId }))
|
||||
render(<CollectionsPage />)
|
||||
// A second click on the marker of the open place used to toggle it away, which
|
||||
// read as the details refusing to open. The map background is what clears.
|
||||
mapPanelProps.current!.onSelect(10)
|
||||
expect(setSelectedPlaceId).toHaveBeenLastCalledWith(10)
|
||||
mapPanelProps.current!.onDeselect()
|
||||
expect(setSelectedPlaceId).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,12 +45,16 @@ function CollectionsPageDesktop(): React.ReactElement {
|
||||
const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
|
||||
const canManageLabels = isRealList && c.canEdit
|
||||
|
||||
// Selecting a place toggles it, so clicking it again — or the map background —
|
||||
// clears it. Below the desktop breakpoint the list and map are separate views;
|
||||
// above it the list view is a split with a persistent map that pans to the
|
||||
// selection (the map stays mounted across the list↔map toggle so it animates).
|
||||
// Selecting a place from the list toggles it, so clicking the row again clears
|
||||
// it, as does the map background. A marker click always shows the place: a second
|
||||
// click on the marker of the place that is open read as "the details do not open"
|
||||
// rather than as "close them" (#2431). Below the desktop breakpoint the list and
|
||||
// map are separate views; above it the list view is a split with a persistent map
|
||||
// that pans to the selection (the map stays mounted across the list↔map toggle so
|
||||
// it animates).
|
||||
const mappable = c.mappable
|
||||
const openPlace = (id: number) => c.setSelectedPlaceId(c.selectedPlaceId === id ? null : id)
|
||||
const showPlace = (id: number) => c.setSelectedPlaceId(id)
|
||||
const deselect = () => c.setSelectedPlaceId(null)
|
||||
const toggleView = () => {
|
||||
// Going to the full-map view closes the (list-docked) detail sheet.
|
||||
@@ -59,7 +63,7 @@ function CollectionsPageDesktop(): React.ReactElement {
|
||||
}
|
||||
// Clicking a marker in the full-map view drops back to the split so the list
|
||||
// + detail come into view alongside the map.
|
||||
const onMapSelect = (id: number) => { openPlace(id); if (c.view === 'map') c.setView('list') }
|
||||
const onMapSelect = (id: number) => { showPlace(id); if (c.view === 'map') c.setView('list') }
|
||||
|
||||
const desktopSplit = c.isWide && c.hasMappable
|
||||
const mapShown = c.hasMappable && (c.view === 'map' || c.isWide)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../components/Map
|
||||
import { DawarichTrailPill } from '../components/Map/DawarichTrailPill'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import { DayPlanSidebarTransportDetailModal } from '../components/Planner/DayPlanSidebarTransportDetailModal'
|
||||
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
|
||||
import TripLoadingSplash from '../components/shared/TripLoadingSplash'
|
||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||
@@ -254,7 +255,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
// Page = wiring container: the entire planner state machine (store, tabs,
|
||||
// selection, CRUD handlers with undo, map filters, splash) lives in the hook.
|
||||
const {
|
||||
tripId, navigate, toast, t, language, placesPhotosEnabled,
|
||||
tripId, navigate, toast, t, language, locale, settings, placesPhotosEnabled,
|
||||
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
@@ -296,7 +297,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
|
||||
stayRelease, setStayRelease, confirmStayRelease,
|
||||
visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
|
||||
visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
|
||||
isMobile, isTouch,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
@@ -445,7 +446,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
hasDayDetail={!!showDayDetail && !selectedPlace}
|
||||
reservations={reservations}
|
||||
showReservationStats={true}
|
||||
visibleConnectionIds={visibleConnections}
|
||||
// In road trip mode the rides that seam the drive are drawn as their own arcs
|
||||
// beside the roads, on top of what the reader switched on under Days.
|
||||
visibleConnectionIds={roadtripActive ? roadtripConnections : visibleConnections}
|
||||
onReservationClick={(rid) => {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
if (r) setMapTransportDetail(r)
|
||||
@@ -596,6 +599,15 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onFocusPoint={focusRoadtripPoint}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)}
|
||||
reservations={reservations}
|
||||
onOpenBooking={(rid) => {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
if (!r) return
|
||||
// The day plan's own split: a transport has a detail view with
|
||||
// an edit button on it, a table or a ticket only has its editor.
|
||||
if (TRANSPORT_TYPES.has(r.type)) setMapTransportDetail(r)
|
||||
else openLinkedReservation?.(r)
|
||||
}}
|
||||
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
|
||||
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
|
||||
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
|
||||
@@ -612,6 +624,18 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
collapsedDayIds={collapsedRoadtripDays}
|
||||
onToggleDay={toggleRoadtripDay}
|
||||
/>
|
||||
{/* The booking a terminal, a ride pill or a map endpoint opens. Under
|
||||
Days the day panel owns this dialog; here the day panel is not
|
||||
mounted, so the rail has to bring it along (#2428). */}
|
||||
<DayPlanSidebarTransportDetailModal
|
||||
transportDetail={mapTransportDetail}
|
||||
setTransportDetail={setMapTransportDetail}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
onEdit={can('day_edit', trip) ? (reservation) => { setMapTransportDetail(null); setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined}
|
||||
t={t}
|
||||
locale={locale}
|
||||
timeFormat={settings.time_format || '24h'}
|
||||
/>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
<DayPlanSidebar
|
||||
|
||||
@@ -435,6 +435,20 @@ describe('AdminSettingsTab', () => {
|
||||
expect(toggleFor('Place Enrichment')).toHaveAttribute('aria-label', 'Place Enrichment');
|
||||
});
|
||||
|
||||
it('FE-ADMSET-030d: the Google-only switch sits with the Google options, says what it needs without a key, and toggles through the hook', () => {
|
||||
const admin = renderTab({ placesGoogleOnly: false, hasMapsKey: false });
|
||||
expect(screen.getByText(/Needs a Google Maps API key/)).toBeInTheDocument();
|
||||
expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'false');
|
||||
fireEvent.click(toggleFor('Search with Google only'));
|
||||
expect(admin.handleTogglePlacesGoogleOnly).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-ADMSET-030e: with a key the Google-only row explains what it changes', () => {
|
||||
renderTab({ placesGoogleOnly: true, hasMapsKey: true });
|
||||
expect(screen.getByText(/Every search and every suggestion goes to Google Places/)).toBeInTheDocument();
|
||||
expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-ADMSET-031: the API keys card leads with the TREK index and no weather panel', () => {
|
||||
renderTab();
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
|
||||
placesDetailsEnabled, setPlacesDetailsEnabledState,
|
||||
placesEnrichEnabled, setPlacesEnrichEnabledState,
|
||||
placesGoogleOnly, handleTogglePlacesGoogleOnly,
|
||||
transitProvider, setTransitProviderState,
|
||||
transitGoogleKeySource, setTransitGoogleKeySource,
|
||||
placeShadowEnabled, setPlaceShadowEnabledState,
|
||||
@@ -448,8 +449,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
<GoogleOptions
|
||||
title={t('admin.googleOptions')}
|
||||
summary={t('admin.googleOptionsSummary', {
|
||||
on: [placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled].filter(Boolean).length,
|
||||
total: 4,
|
||||
on: [placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled, placesGoogleOnly].filter(Boolean).length,
|
||||
total: 5,
|
||||
})}
|
||||
>
|
||||
<div className="divide-y divide-edge-faint">
|
||||
@@ -520,6 +521,21 @@ 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. */}
|
||||
<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>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
on={placesGoogleOnly}
|
||||
label={t('admin.placesGoogleOnly.title')}
|
||||
onToggle={handleTogglePlacesGoogleOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GoogleOptions>
|
||||
</ProviderBlock>
|
||||
|
||||
@@ -105,6 +105,16 @@ export function useAdmin() {
|
||||
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||
useEffect(() => { adminApi.getPlacesEnrich().then(d => setPlacesEnrichEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Search and suggestions from Google alone. Admin-only state: the search itself
|
||||
// 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
|
||||
// request-time fallback to Transitous is silent by design.
|
||||
@@ -481,6 +491,7 @@ export function useAdmin() {
|
||||
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
|
||||
placesDetailsEnabled, setPlacesDetailsEnabledState,
|
||||
placesEnrichEnabled, setPlacesEnrichEnabledState,
|
||||
placesGoogleOnly, handleTogglePlacesGoogleOnly,
|
||||
transitProvider, setTransitProviderState,
|
||||
transitGoogleKeySource, setTransitGoogleKeySource,
|
||||
placeShadowEnabled, setPlaceShadowEnabledState,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
distanceBetweenGeoPoints,
|
||||
groupPhotosByDate,
|
||||
matchJourneyEntries,
|
||||
posterlessVideo,
|
||||
sortProviderPhotos,
|
||||
utcOffsetMinutesForDay,
|
||||
} from './JourneyDetailPage.helpers';
|
||||
@@ -207,3 +208,20 @@ describe('matchJourneyEntries', () => {
|
||||
expect(matchJourneyEntries(entries, 'reykjavik')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('posterlessVideo (#2341)', () => {
|
||||
it('is a local clip whose poster frame was never grabbed', () => {
|
||||
expect(posterlessVideo({ media_type: 'video', provider: 'local', thumbnail_path: null })).toBe(true);
|
||||
expect(posterlessVideo({ media_type: 'video', provider: 'local' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is not a clip that has its poster, whatever the file is called', () => {
|
||||
expect(posterlessVideo({ media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' })).toBe(false);
|
||||
});
|
||||
|
||||
it('is never a photo, and never a provider clip, whose poster the provider serves', () => {
|
||||
expect(posterlessVideo({ media_type: 'image', provider: 'local', thumbnail_path: null })).toBe(false);
|
||||
expect(posterlessVideo({ provider: 'local', thumbnail_path: null })).toBe(false);
|
||||
expect(posterlessVideo({ media_type: 'video', provider: 'immich', thumbnail_path: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,21 @@ export function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original'
|
||||
return `/api/photos/${p.photo_id}/${size}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A clip that came up without a poster frame: the browser could not decode it
|
||||
* at upload time, so nothing was grabbed. Its thumbnail route answers 404 on
|
||||
* purpose rather than streaming the whole file, and an <img> pointed at it
|
||||
* shows the broken-image glyph (#2341). A provider clip is not one of these,
|
||||
* its poster comes from the provider whether or not a local path is recorded.
|
||||
*/
|
||||
export function posterlessVideo(p: {
|
||||
media_type?: string | null;
|
||||
provider?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
}): boolean {
|
||||
return p.media_type === 'video' && p.provider === 'local' && !p.thumbnail_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which calendar day a provider photo belongs to.
|
||||
*
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDay
|
||||
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
|
||||
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
|
||||
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
|
||||
import { carrierReservationIds, type CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
||||
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
|
||||
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
|
||||
@@ -76,6 +77,7 @@ import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlace
|
||||
|
||||
/** Stable empty list so the road trip hook stays inert while its mode is off. */
|
||||
const EMPTY_DAYS: Day[] = []
|
||||
const EMPTY_RESERVATIONS: Reservation[] = []
|
||||
|
||||
/**
|
||||
* Trip planner page logic — the big one. Owns the trip store wiring, addon
|
||||
@@ -93,7 +95,7 @@ export function useTripPlanner() {
|
||||
const tripId = id ? Number(id) : Number.NaN
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const { settings } = useSettingsStore()
|
||||
const roadtripSettings = useRoadtripSettings(s => s, tripId)
|
||||
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
|
||||
@@ -743,7 +745,18 @@ export function useTripPlanner() {
|
||||
roadtripVias.byDay,
|
||||
dayBoundaries.boundaries,
|
||||
tripAccommodations,
|
||||
roadtripFeedActive ? reservations : EMPTY_RESERVATIONS,
|
||||
)
|
||||
/**
|
||||
* The bookings the drive is seamed by, drawn as their own arcs beside the roads: a
|
||||
* flight's route on the road trip map is the booking's line, the same one the day plan
|
||||
* draws, so the ride shows where the road stops (#2428). On top of whatever the reader
|
||||
* switched on by hand under Days.
|
||||
*/
|
||||
const roadtripConnections = useMemo(() => {
|
||||
const rides = carrierReservationIds(roadtripRoutes.days)
|
||||
return rides.length ? [...new Set([...visibleConnections, ...rides])] : visibleConnections
|
||||
}, [roadtripRoutes.days, visibleConnections])
|
||||
// Lives here rather than in the panel because the map draws what it finds.
|
||||
// The trip comes with it for the vehicle: an electric car looks for chargers rather
|
||||
// than for pumps, and that preference is stored per trip.
|
||||
@@ -1646,7 +1659,11 @@ export function useTripPlanner() {
|
||||
* looks exactly like a drag that did nothing.
|
||||
*/
|
||||
const anchorFor = useCallback((lat: number, lng: number, onlyDayId?: number) => {
|
||||
let best: { dayId: number; afterIndex: number; offRouteKm: number } | null = null
|
||||
// `terminal` names the anchor when it is one end of a ride rather than a stored stop
|
||||
// (#2428). Nothing can be filed against a terminal: it stands in for no assignment,
|
||||
// so a via anchored to it would be stored at a position that belongs to the stop
|
||||
// after it and bend that stop's road instead. The callers decide what to refuse.
|
||||
let best: { dayId: number; afterIndex: number; offRouteKm: number; terminal: CarrierTerminal['role'] | null } | null = null
|
||||
for (const day of roadtripRoutes.days) {
|
||||
// NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored
|
||||
// on, but that day's stops are no longer all on the card of the same name: after a
|
||||
@@ -1682,7 +1699,7 @@ export function useTripPlanner() {
|
||||
if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) {
|
||||
const owner = arrivedFrom.ownerDayId ?? day.dayId
|
||||
if (onlyDayId !== undefined && owner !== onlyDayId) continue
|
||||
best = { dayId: owner, afterIndex: arrivedFrom.ownerIndex ?? 0, offRouteKm: hit.offRouteKm }
|
||||
best = { dayId: owner, afterIndex: arrivedFrom.ownerIndex ?? 0, offRouteKm: hit.offRouteKm, terminal: arrivedFrom.carrier?.role ?? null }
|
||||
continue
|
||||
}
|
||||
const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
|
||||
@@ -1699,7 +1716,7 @@ export function useTripPlanner() {
|
||||
const owner = anchor.ownerDayId ?? day.dayId
|
||||
// A drag stays on its own day; a fresh click may land wherever it landed.
|
||||
if (onlyDayId !== undefined && owner !== onlyDayId) continue
|
||||
best = { dayId: owner, afterIndex: anchor.ownerIndex ?? at, offRouteKm: hit.offRouteKm }
|
||||
best = { dayId: owner, afterIndex: anchor.ownerIndex ?? at, offRouteKm: hit.offRouteKm, terminal: anchor.carrier?.role ?? null }
|
||||
}
|
||||
return best
|
||||
}, [roadtripRoutes.days])
|
||||
@@ -1720,7 +1737,9 @@ export function useTripPlanner() {
|
||||
*/
|
||||
const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => {
|
||||
const anchor = anchorFor(lat, lng)
|
||||
if (!anchor) return null
|
||||
// Nothing is stopped at on a flight. Behind an arrival terminal or a hire car's desk
|
||||
// is a road, and a stop there is the first stop after landing or after the pick-up.
|
||||
if (!anchor || anchor.terminal === 'departure') return null
|
||||
for (const day of roadtripRoutes.days) {
|
||||
const at = day.stops.findIndex(stop => stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
|
||||
if (at >= 0) return { dayId: day.dayId, position: at + 1, offRouteKm: anchor.offRouteKm }
|
||||
@@ -1805,8 +1824,10 @@ export function useTripPlanner() {
|
||||
const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
|
||||
if (!can('day_edit', trip)) return
|
||||
const best = anchorFor(lat, lng)
|
||||
// A click that landed on some other line is not a via anywhere.
|
||||
if (!best || best.offRouteKm > 2) return
|
||||
// A click that landed on some other line is not a via anywhere. Neither is one on a
|
||||
// ride, or on the road out of a terminal: a via is filed by the position of a stored
|
||||
// stop, and a terminal is not one.
|
||||
if (!best || best.offRouteKm > 2 || best.terminal) return
|
||||
try {
|
||||
await roadtripVias.add(best.dayId, best.afterIndex, lat, lng)
|
||||
} catch (err: unknown) {
|
||||
@@ -1826,7 +1847,7 @@ export function useTripPlanner() {
|
||||
// just says which leg gets bent, and the router answers the rest.
|
||||
const anchor = anchorFor(lat, lng, dayId)
|
||||
try {
|
||||
await roadtripVias.move(dayId, id, lat, lng, anchor?.afterIndex)
|
||||
await roadtripVias.move(dayId, id, lat, lng, anchor && !anchor.terminal ? anchor.afterIndex : undefined)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
@@ -2592,7 +2613,7 @@ export function useTripPlanner() {
|
||||
}, [isLoading, trip])
|
||||
|
||||
return {
|
||||
tripId, navigate, toast, t, language, settings, placesPhotosEnabled,
|
||||
tripId, navigate, toast, t, language, locale, settings, placesPhotosEnabled,
|
||||
trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
@@ -2642,7 +2663,7 @@ export function useTripPlanner() {
|
||||
routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
|
||||
visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
|
||||
visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
|
||||
isMobile, isTouch,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
|
||||
@@ -252,11 +252,17 @@ 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). Append.
|
||||
// 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]: [...existing, incoming],
|
||||
[dayKey]: ordered.map((a, i) => (a.order_index === i ? a : { ...a, order_index: i })),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -187,6 +187,10 @@
|
||||
.trek-dash .hero-content {
|
||||
position: absolute; inset: 0; z-index: 2; padding: 32px 32px 26px;
|
||||
display: grid; grid-template-rows: auto 1fr auto; color: #fff;
|
||||
/* The one column may not grow past the hero. An auto track takes the title's
|
||||
min-content width, and a title that is one long word made the track, the tools
|
||||
row and the boarding pass as wide as that word, with the tools off the card. */
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.trek-dash .hero-top { display: flex; justify-content: space-between; align-items: start; }
|
||||
.trek-dash .hero-badge {
|
||||
@@ -221,14 +225,21 @@
|
||||
}
|
||||
.trek-dash .hero-tool:hover { background: oklch(0.22 0.01 260 / .78); transform: scale(1.05); }
|
||||
.trek-dash .hero-tool svg { width: 17px; height: 17px; }
|
||||
.trek-dash .hero-title-block { align-self: end; padding-bottom: 24px; }
|
||||
.trek-dash .hero-title-block { align-self: end; padding-bottom: 24px; min-width: 0; }
|
||||
.trek-dash .hero-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
font-size: 11.5px; letter-spacing: 0.22em; text-transform: uppercase;
|
||||
opacity: .88; margin-bottom: 16px; font-weight: 500;
|
||||
}
|
||||
.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; text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4); }
|
||||
.trek-dash .hero-title {
|
||||
font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0;
|
||||
text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4);
|
||||
/* Two lines at most, a word longer than the card broken inside it: the rows above
|
||||
and below have a fixed budget and a third line would run into the badge row. */
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ----------------- boarding pass ----------------- */
|
||||
/* The pass is split into an outer wrapper (left notch) and an inner glass panel
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
// jsdom lays nothing out, so the rules themselves are the assertion, the way
|
||||
// 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)
|
||||
expect(at, `${selector} missing from dashboard.css`).toBeGreaterThan(-1)
|
||||
return css.slice(at, css.indexOf('}', at) + 1)
|
||||
}
|
||||
|
||||
it('FE-DASH-CSS-001: a hero title that is one long word cannot widen the card', () => {
|
||||
// Reported from Discord: with a title like "asdaaaa…" the tools row and the
|
||||
// boarding pass grew to the width of the word, and edit, clone, archive and
|
||||
// delete sat off the card. The grid column may not grow past the hero, and the
|
||||
// title breaks inside the card and stops at two lines.
|
||||
expect(block('.trek-dash .hero-content {')).toMatch(/grid-template-columns:\s*minmax\(0,\s*1fr\)/)
|
||||
expect(block('.trek-dash .hero-title-block {')).toMatch(/min-width:\s*0/)
|
||||
const title = block('.trek-dash .hero-title {')
|
||||
expect(title).toMatch(/overflow-wrap:\s*anywhere/)
|
||||
expect(title).toMatch(/-webkit-line-clamp:\s*2/)
|
||||
expect(title).toMatch(/overflow:\s*hidden/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { offersGoogleRetry, sourceLabelFor } from './placeSource'
|
||||
|
||||
describe('offersGoogleRetry', () => {
|
||||
it('FE-PLACESOURCE-001: offers Google for a list the index or OpenStreetMap answered, only where a key exists', () => {
|
||||
expect(offersGoogleRetry('trek-places+openstreetmap', true)).toBe(true)
|
||||
expect(offersGoogleRetry('openstreetmap', true)).toBe(true)
|
||||
expect(offersGoogleRetry('trek-places+plugin', true)).toBe(true)
|
||||
expect(offersGoogleRetry('trek-places+openstreetmap', false)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-PLACESOURCE-002: a list Google produced, the offline cache and no list at all have nowhere further to go', () => {
|
||||
expect(offersGoogleRetry('google', true)).toBe(false)
|
||||
expect(offersGoogleRetry('google+plugin', true)).toBe(false)
|
||||
expect(offersGoogleRetry('offline-cache', true)).toBe(false)
|
||||
expect(offersGoogleRetry('', true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sourceLabelFor', () => {
|
||||
it('FE-PLACESOURCE-003: a row names its own index, else the list it came from', () => {
|
||||
const t = (k: string) => k
|
||||
expect(sourceLabelFor({ source: 'openstreetmap' }, 'trek-places+openstreetmap', t)).toBe('OpenStreetMap')
|
||||
expect(sourceLabelFor({}, 'google', t)).toBe('Google')
|
||||
expect(sourceLabelFor({}, 'amap', t)).toBe('places.source.amap')
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,21 @@ 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
|
||||
|
||||
@@ -76,6 +76,8 @@ export function buildAdminHook(overrides: Record<string, unknown> = {}): AdminHo
|
||||
placesDetailsEnabled: false,
|
||||
setPlacesDetailsEnabledState: vi.fn(),
|
||||
placesEnrichEnabled: true,
|
||||
placesGoogleOnly: false,
|
||||
handleTogglePlacesGoogleOnly: vi.fn(),
|
||||
setPlacesEnrichEnabledState: vi.fn(),
|
||||
// Fail-open on the server, so the harness starts where an unset row lands.
|
||||
trekPlacesEnabled: true,
|
||||
|
||||
@@ -3,17 +3,27 @@ import MBookingsTab from '../../../../src/mobile/screens/trip/tabs/MBookingsTab'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { openFile } from '../../../../src/utils/fileDownload'
|
||||
import type { Day, Reservation, TripFile } from '../../../../src/types'
|
||||
import { usePluginStore } from '../../../../src/store/pluginStore'
|
||||
import { buildSettings } from '../../../helpers/factories'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
import { seedStore } from '../../../helpers/store'
|
||||
|
||||
// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-018
|
||||
// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-023
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
|
||||
openFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
|
||||
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
|
||||
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
|
||||
),
|
||||
}))
|
||||
|
||||
const SEAT_MAP = { id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'reservation-detail' } as const
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
|
||||
@@ -93,6 +103,7 @@ function cardOf(title: string): HTMLElement {
|
||||
describe('MBookingsTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(openFile).mockClear()
|
||||
seedStore(usePluginStore, { plugins: [] })
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => {
|
||||
@@ -285,4 +296,29 @@ describe('MBookingsTab', () => {
|
||||
// the check-in cell and the time field both read 09:00
|
||||
expect(within(card).getAllByText('09:00')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-022: mounts a reservation-detail plugin on every booking card, scoped to that booking (#2440)', () => {
|
||||
seedStore(usePluginStore, {
|
||||
plugins: [SEAT_MAP, { id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'sidebar' }],
|
||||
})
|
||||
const { planner: p } = renderTab()
|
||||
const frame = within(cardOf('Hotel Granvia')).getByTestId('plugin-frame')
|
||||
expect(frame).toHaveAttribute('data-plugin', 'seat-map')
|
||||
expect(frame).toHaveAttribute('data-trip', '7')
|
||||
expect(frame).toHaveAttribute('data-reservation', '201')
|
||||
expect(frame).toHaveAttribute('data-surface', 'detail-slot')
|
||||
// the slot sits outside the card's body button: a tap in it opens no editor
|
||||
fireEvent.click(frame)
|
||||
expect(p.setShowReservationModal).not.toHaveBeenCalled()
|
||||
expect(within(cardOf('Bamboo walk')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '204')
|
||||
expect(screen.getAllByTestId('plugin-frame')).toHaveLength(4)
|
||||
expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-023: compact mode drops the plugin frames with the rest of the body', () => {
|
||||
seedStore(usePluginStore, { plugins: [SEAT_MAP] })
|
||||
renderTab(planner(), buildShell({ bookingsCompact: true }))
|
||||
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,17 +3,27 @@ import MTransportsTab from '../../../../src/mobile/screens/trip/tabs/MTransports
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { openFile } from '../../../../src/utils/fileDownload'
|
||||
import type { Day, Reservation, TripFile } from '../../../../src/types'
|
||||
import { usePluginStore } from '../../../../src/store/pluginStore'
|
||||
import { buildSettings } from '../../../helpers/factories'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
import { seedStore } from '../../../helpers/store'
|
||||
|
||||
// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-022
|
||||
// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-028
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
|
||||
openFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
|
||||
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
|
||||
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
|
||||
),
|
||||
}))
|
||||
|
||||
const FLIGHT_TRACKER = { id: 'flight-tracker', name: 'Flight Tracker', type: 'widget', icon: null, slot: 'reservation-detail' } as const
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
|
||||
@@ -101,6 +111,7 @@ function cardOf(title: string): HTMLElement {
|
||||
describe('MTransportsTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(openFile).mockClear()
|
||||
seedStore(usePluginStore, { plugins: [] })
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => {
|
||||
@@ -322,4 +333,35 @@ describe('MTransportsTab', () => {
|
||||
// nobody is assigned, so the traveler filter row stays away
|
||||
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-027: mounts a reservation-detail plugin on every card, scoped to that reservation (#2440)', () => {
|
||||
seedStore(usePluginStore, {
|
||||
plugins: [
|
||||
FLIGHT_TRACKER,
|
||||
{ id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'hero' },
|
||||
{ id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'place-detail' },
|
||||
],
|
||||
})
|
||||
const { shell } = renderTab()
|
||||
const frame = within(cardOf('HND to ITM')).getByTestId('plugin-frame')
|
||||
expect(frame).toHaveAttribute('data-plugin', 'flight-tracker')
|
||||
expect(frame).toHaveAttribute('data-trip', '7')
|
||||
expect(frame).toHaveAttribute('data-reservation', '101')
|
||||
expect(frame).toHaveAttribute('data-surface', 'detail-slot')
|
||||
// the slot sits outside the card's body button: a tap in it opens no sheet
|
||||
fireEvent.click(frame)
|
||||
expect(shell.openSheet).not.toHaveBeenCalled()
|
||||
expect(within(cardOf('Metro to hotel')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '104')
|
||||
// one frame per transport card, and only for the reservation-detail slot
|
||||
expect(screen.getAllByTestId('plugin-frame')).toHaveLength(5)
|
||||
expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull()
|
||||
expect(document.querySelector('[data-plugin="seat-map"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-028: compact mode drops the plugin frames with the rest of the body', () => {
|
||||
seedStore(usePluginStore, { plugins: [FLIGHT_TRACKER] })
|
||||
renderTab(planner(), buildShell({ transportsCompact: true }))
|
||||
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { rtlTextAlias } from './rtlTextAlias.js';
|
||||
import { rtlTextAlias, plyrSpriteAlias } from './rtlTextAlias.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// The version this bundle is built as, baked in at build time. The release image
|
||||
@@ -259,7 +259,7 @@ export default defineConfig(({ mode }) => ({
|
||||
},
|
||||
}),
|
||||
].filter(Boolean),
|
||||
resolve: { alias: [rtlTextAlias] },
|
||||
resolve: { alias: [rtlTextAlias, plyrSpriteAlias] },
|
||||
build: {
|
||||
// Pin the output level instead of inheriting whatever the current Vite default
|
||||
// is, so a toolchain bump can't silently change which browsers still work.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { rtlTextAlias } from './rtlTextAlias.js';
|
||||
import { rtlTextAlias, plyrSpriteAlias } from './rtlTextAlias.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const UI_VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
define: { __TREK_UI_VERSION__: JSON.stringify(UI_VERSION) },
|
||||
plugins: [react()],
|
||||
test: {
|
||||
alias: [rtlTextAlias],
|
||||
alias: [rtlTextAlias, plyrSpriteAlias],
|
||||
root: '.',
|
||||
globals: true,
|
||||
environment: './tests/environment/jsdom-native-abort.ts',
|
||||
|
||||
Generated
+20
-329
@@ -9321,26 +9321,6 @@
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.22",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz",
|
||||
@@ -9360,20 +9340,6 @@
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.11.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
||||
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
@@ -9396,15 +9362,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz",
|
||||
@@ -9415,17 +9372,6 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||
@@ -9555,30 +9501,6 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
@@ -9855,12 +9777,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/cli-width": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
|
||||
@@ -10406,30 +10322,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -10573,6 +10465,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -10828,15 +10721,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
@@ -11473,15 +11357,6 @@
|
||||
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
@@ -11797,12 +11672,6 @@
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||
@@ -12029,12 +11898,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
|
||||
@@ -12242,12 +12105,6 @@
|
||||
"omggif": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
@@ -12765,12 +12622,6 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
@@ -15464,18 +15315,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
@@ -15521,12 +15360,6 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -15788,12 +15621,6 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
@@ -15830,16 +15657,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.92.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
|
||||
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.9.2",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
|
||||
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
@@ -16764,33 +16588,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -17048,16 +16845,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -17390,21 +17177,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
@@ -18623,51 +18395,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
@@ -19038,15 +18765,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
|
||||
@@ -19286,34 +19004,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs/node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
|
||||
@@ -19834,18 +19524,6 @@
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -21783,7 +21461,7 @@
|
||||
"@trek/shared": "*",
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"compression": "^1.8.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
@@ -21950,6 +21628,19 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"server/node_modules/better-sqlite3": {
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
||||
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"server/node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
"@trek/shared": "*",
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"compression": "^1.8.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared';
|
||||
import { readEnv } from '../app-config';
|
||||
import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto';
|
||||
import { seedDocumentProviders } from './document-provider-seed';
|
||||
import { reseatBookedNights } from './reseat-booked-nights';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
@@ -5212,6 +5213,19 @@ function runMigrations(db: Database.Database): void {
|
||||
ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries;
|
||||
`);
|
||||
},
|
||||
|
||||
/*
|
||||
* Seat every booked night where its check-in says, the way a night booked
|
||||
* today is seated. The night leads its day now; the stops a booking put on
|
||||
* their check-in day before this release sit last, and the trips people
|
||||
* already have would keep that order until somebody edits the check-in.
|
||||
* The rules, and the drawn roads that follow the stops, are in
|
||||
* reseat-booked-nights.ts. Re-runnable.
|
||||
*/
|
||||
() => {
|
||||
const seated = reseatBookedNights(db);
|
||||
if (seated > 0) console.log(`[DB] Seated ${seated} booked night(s) at the head of their day`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
type DayRow = { id: number; at: string | null; located: number };
|
||||
type Via = { id: number; after_order_index: number; sequence: number };
|
||||
|
||||
/**
|
||||
* Seat every booked night where its check-in says, the way a night booked today is
|
||||
* seated (AccommodationsService.positionForCheckIn).
|
||||
*
|
||||
* 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
|
||||
* appended it too. The night leads its day now: first, behind only a stop whose own
|
||||
* clock is at or before the check-in, with the stops that carry no hour following it.
|
||||
* Without this step every trip planned before the change keeps the old order until
|
||||
* somebody edits the check-in.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Runs inside the migration's transaction. Re-runnable: a night already in its seat is
|
||||
* passed over. 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
|
||||
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
|
||||
`);
|
||||
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;
|
||||
for (const night of nights) {
|
||||
const rows = dayRows.all(night.day_id) as DayRow[];
|
||||
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 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -265,6 +265,13 @@ export function applyGlobalMiddleware(
|
||||
],
|
||||
workerSrc: ["'self'", "blob:"],
|
||||
childSrc: ["'self'", "blob:"],
|
||||
// blob: because a picked clip is previewed and its poster frame grabbed
|
||||
// through a <video> on an object URL, before any byte reaches the server.
|
||||
// Unset, this fell back to default-src, which refuses blob: outright: the
|
||||
// editor showed nothing and every clip landed without a poster, so its
|
||||
// thumbnail answered 404 (#2341). Invisible in dev, where Vite serves the
|
||||
// document without this header.
|
||||
mediaSrc: ["'self'", "blob:"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
|
||||
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
|
||||
|
||||
@@ -24,7 +24,7 @@ export class ThingsMcp {
|
||||
@Tool({
|
||||
name: 'list_things',
|
||||
description: 'List all things.',
|
||||
inputSchema: {}, // ZodRawShape, passed straight to the SDK
|
||||
inputSchema: {}, // ZodRawShape, registered as a strict object
|
||||
annotations: { readOnlyHint: true },
|
||||
access: { group: 'things', mode: 'read' }, // resolved by the accessPolicy
|
||||
})
|
||||
@@ -34,7 +34,7 @@ export class ThingsMcp {
|
||||
}
|
||||
```
|
||||
|
||||
- `@Tool(options)` — mirrors `server.registerTool` (`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`, `_meta`). Handler: `(args, ctx)`; `args` is `{}` when no `inputSchema` was declared.
|
||||
- `@Tool(options)` — mirrors `server.registerTool` (`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`, `_meta`). Handler: `(args, ctx)`; `args` is `{}` when no `inputSchema` was declared. A shape is registered as `z.strictObject`, so an argument the tool never declared is refused (`Unrecognized key`) and `tools/list` advertises `additionalProperties: false`; the SDK's own wrapping would strip it and run the handler regardless. A whole Zod schema is passed through as written.
|
||||
- `@Resource(options)` — fixed URI (`uri`, `mimeType`, …). Handler: `(uri: URL, ctx)`.
|
||||
- `@ResourceTemplate(options)` — `uriTemplate` (RFC 6570). Handler: `(uri: URL, variables, ctx)`. (No `list`/`complete` template callbacks yet — extend when a domain needs them.)
|
||||
- `@Prompt(options)` — mirrors `registerPrompt` (`argsSchema` entries must be string-valued Zod schemas). Handler: `(args, ctx)`.
|
||||
|
||||
@@ -15,12 +15,24 @@ import type {
|
||||
} from './types';
|
||||
import { ResourceTemplate as SdkResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface BoundEntry {
|
||||
entry: McpEntry;
|
||||
instance: object;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shape is registered strict: the SDK's own z.object wrapping would strip an
|
||||
* undeclared key and run the handler as if nothing was wrong, and strict also
|
||||
* puts `additionalProperties: false` into tools/list. A whole schema is the
|
||||
* author's call and passes through untouched.
|
||||
*/
|
||||
function strictInputSchema(schema: ToolOptions['inputSchema']): unknown {
|
||||
if (schema === undefined || '_zod' in schema) return schema;
|
||||
return z.strictObject(schema);
|
||||
}
|
||||
|
||||
export interface McpRegistryOptions {
|
||||
accessPolicy?: McpAccessPolicy;
|
||||
validateAccess?: McpAccessValidator;
|
||||
@@ -30,9 +42,9 @@ type AnyHandler = (this: unknown, ...handlerArgs: unknown[]) => unknown;
|
||||
|
||||
/**
|
||||
* Structural view of the SDK registration surface. The SDK's real signatures
|
||||
* are generic over the Zod shapes; the registry passes schemas straight
|
||||
* through and binds `ctx` where the SDK would pass `extra`, so it needs (and
|
||||
* exposes) none of that type-level machinery.
|
||||
* are generic over the Zod shapes; the registry hands schemas over as objects
|
||||
* and binds `ctx` where the SDK would pass `extra`, so it needs (and exposes)
|
||||
* none of that type-level machinery.
|
||||
*/
|
||||
interface LooseRegistrar {
|
||||
registerTool(name: string, config: Record<string, unknown>, cb: (...cbArgs: unknown[]) => unknown): unknown;
|
||||
@@ -294,7 +306,7 @@ export class McpRegistry {
|
||||
const config: Record<string, unknown> = {
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
inputSchema: options.inputSchema,
|
||||
inputSchema: strictInputSchema(options.inputSchema),
|
||||
outputSchema: options.outputSchema,
|
||||
annotations: options.annotations,
|
||||
_meta: options._meta,
|
||||
|
||||
@@ -126,8 +126,9 @@ export interface McpZodSchema {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the SDK's `registerTool` config. `inputSchema` is passed straight
|
||||
* through — the SDK does the parsing/validation.
|
||||
* Mirrors the SDK's `registerTool` config. A shape `inputSchema` is registered
|
||||
* as a strict object (see registry.ts); a whole schema is passed as written. The
|
||||
* SDK does the parsing/validation either way.
|
||||
* `annotations`/`_meta` are kept structural so the package's public d.ts
|
||||
* never imports deep SDK subpaths.
|
||||
*/
|
||||
|
||||
@@ -164,9 +164,16 @@ export class AccommodationsService {
|
||||
return this.mirrorStay(accommodationId, placeId, dayId, checkIn);
|
||||
}
|
||||
|
||||
/** Carry a stay's own stop over to where the stay now is. */
|
||||
moveStayStop(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror {
|
||||
return this.remirrorStay(accommodationId, placeId, dayId, checkIn);
|
||||
/** Carry a stay's own stop over to where the stay now is. `checkInChanged` says the
|
||||
* booking was given a new hour, which seats the night afresh (see remirrorStay). */
|
||||
moveStayStop(
|
||||
accommodationId: number,
|
||||
placeId: number | null,
|
||||
dayId: number,
|
||||
checkIn?: string | null,
|
||||
opts: { checkInChanged?: boolean } = {},
|
||||
): AccommodationMirror {
|
||||
return this.remirrorStay(accommodationId, placeId, dayId, checkIn, opts);
|
||||
}
|
||||
|
||||
/** Take back the stops of a stay that is being deleted elsewhere. */
|
||||
@@ -277,42 +284,47 @@ export class AccommodationsService {
|
||||
/**
|
||||
* Where in the day the booked night belongs.
|
||||
*
|
||||
* Last, unless the booking names an hour. A night usually ends the day, which is why
|
||||
* that is the default — but a check-in at eleven says the traveller is at the desk
|
||||
* before whatever they pinned to the afternoon, and dropping the stop behind it built
|
||||
* a drive that visits the hotel after the sixteen-hundred it was booked around.
|
||||
*
|
||||
* Only stops that carry a clock of their own are compared. One without a time takes
|
||||
* its position from the chain, not from the hour, so pushing past it would move a
|
||||
* stop the traveller placed deliberately.
|
||||
* 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 | undefined {
|
||||
if (!checkIn) return undefined;
|
||||
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) AS at
|
||||
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);
|
||||
const later = rows.find(row => row.at !== null && row.at > checkIn);
|
||||
return later?.order_index;
|
||||
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 already sits where its check-in says it should.
|
||||
* 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 — their place in the chain is
|
||||
* the traveller's doing, not a clock's.
|
||||
* 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) AS at
|
||||
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);
|
||||
@@ -493,7 +505,7 @@ export class AccommodationsService {
|
||||
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 !== undefined && seat < end) {
|
||||
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,
|
||||
@@ -519,9 +531,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, so the stop lands at the end of the day with the
|
||||
// order_index every other new assignment gets. Evening is where you arrive at a
|
||||
// hotel, and the day planner has no drive to position it against anyway.
|
||||
// Through AssignmentsService, seated where the check-in says (see
|
||||
// positionForCheckIn), 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
|
||||
@@ -590,14 +601,24 @@ export class AccommodationsService {
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private remirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror {
|
||||
private remirrorStay(
|
||||
accommodationId: number,
|
||||
placeId: number | null,
|
||||
dayId: number,
|
||||
checkIn?: string | null,
|
||||
opts: { checkInChanged?: boolean } = {},
|
||||
): AccommodationMirror {
|
||||
const own = this.ownStops(accommodationId);
|
||||
if (own.length === 0) return noMirror();
|
||||
if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) {
|
||||
// Same place, same day — but a check-in moved to a different hour can still put
|
||||
// the night somewhere else in the chain, and a stop sitting after the afternoon
|
||||
// it was booked around is the plan reading back wrong.
|
||||
if (this.seatedByCheckIn(dayId, own[0].id, checkIn)) return noMirror();
|
||||
// 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);
|
||||
if (settled) return noMirror();
|
||||
}
|
||||
|
||||
const mirror = noMirror();
|
||||
@@ -689,7 +710,9 @@ export class AccommodationsService {
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?',
|
||||
newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id
|
||||
);
|
||||
return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn);
|
||||
return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn, {
|
||||
checkInChanged: fields.check_in !== undefined && (fields.check_in || null) !== (existing.check_in || null),
|
||||
});
|
||||
});
|
||||
|
||||
// Sync check-in/out/confirmation to every linked reservation. The booking form
|
||||
|
||||
@@ -198,6 +198,15 @@ export class AddonsService {
|
||||
getPlaceShadow() { return this.readFlag('place_shadow_enabled'); }
|
||||
updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); }
|
||||
|
||||
/**
|
||||
* Search and suggestions from Google alone, skipping the index and
|
||||
* OpenStreetMap. Fail-closed for the same reason as the shadow log: nobody had
|
||||
* this before it existed, so an absent row correctly means off. MapsService
|
||||
* reads the same key the same way, and only once Google holds the key slot.
|
||||
*/
|
||||
getPlacesGoogleOnly() { return this.readFlag('places_google_only'); }
|
||||
updatePlacesGoogleOnly(enabled: boolean) { return this.writeFlag('places_google_only', enabled); }
|
||||
|
||||
/**
|
||||
* Enrichment reads fail-OPEN, unlike the three switches above.
|
||||
*
|
||||
|
||||
@@ -243,6 +243,16 @@ export class AdminController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('places-google-only')
|
||||
getPlacesGoogleOnly() { return this.addons.getPlacesGoogleOnly(); }
|
||||
|
||||
@Put('places-google-only')
|
||||
updatePlacesGoogleOnly(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = this.addons.updatePlacesGoogleOnly(body.enabled);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.places_google_only', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('places-enrich')
|
||||
getPlacesEnrich() { return this.addons.getPlacesEnrich(); }
|
||||
|
||||
|
||||
@@ -25,13 +25,13 @@ const budgetAddonOn = addonGate(ADDON_IDS.BUDGET);
|
||||
* signed like the REST contract's (#2176): a negative payer is the recipient
|
||||
* of a refund recorded as a negative expense.
|
||||
*/
|
||||
const payersSchema = z.array(z.object({
|
||||
const payersSchema = z.array(z.strictObject({
|
||||
user_id: z.number().int().positive(),
|
||||
amount: z.number(),
|
||||
})).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.');
|
||||
|
||||
/** Reusable Zod shape for an unequal split: what each participant owes. Signed, like the REST contract (#2176). */
|
||||
const splitMembersSchema = z.array(z.object({
|
||||
const splitMembersSchema = z.array(z.strictObject({
|
||||
user_id: z.number().int().positive(),
|
||||
amount: z.number(),
|
||||
})).describe('Unequal split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Ask the user; do not guess.');
|
||||
|
||||
@@ -63,7 +63,7 @@ type EntryVisibility = z.infer<typeof ENTRY_VISIBILITY>;
|
||||
* only the half it has, which is what the entry editor does when a place was
|
||||
* all good or all bad. The service stores nothing when both come back empty.
|
||||
*/
|
||||
const PROS_CONS = z.object({
|
||||
const PROS_CONS = z.strictObject({
|
||||
pros: z.array(z.string()).default([]),
|
||||
cons: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ export class MapsController {
|
||||
@Query('lang') lang?: string,
|
||||
): Promise<MapsSearchResult> {
|
||||
try {
|
||||
return await this.maps.search(user.id, body.query, lang, body.locationBias);
|
||||
return await this.maps.search(user.id, body.query, lang, body.locationBias, body.provider);
|
||||
} catch (err: unknown) {
|
||||
console.error('Maps search error:', err);
|
||||
throw toHttpException(err, 'Search error', 500);
|
||||
|
||||
@@ -95,7 +95,7 @@ export class MapsMcp {
|
||||
z.enum(POI_CATEGORY_KEYS),
|
||||
z.array(z.enum(POI_CATEGORY_KEYS)).min(1).max(MAX_POI_CATEGORIES),
|
||||
]).describe('Which kind of place to look for. Pass several to cover them in one query rather than one request per kind'),
|
||||
bbox: z.object({
|
||||
bbox: z.strictObject({
|
||||
south: z.number().min(-90).max(90).describe('Southern edge, latitude'),
|
||||
west: z.number().min(-180).max(180).describe('Western edge, longitude'),
|
||||
north: z.number().min(-90).max(90).describe('Northern edge, latitude'),
|
||||
|
||||
@@ -664,6 +664,12 @@ type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; ln
|
||||
* predates Amap, means `auto`, which keeps Google.
|
||||
*/
|
||||
export const PLACES_PROVIDER_SETTING = 'places_provider';
|
||||
/**
|
||||
* The admin switch that hands search and suggestions to Google alone. Off, the
|
||||
* index and OpenStreetMap answer first and Google is only asked when they find
|
||||
* nothing, which is what every install has had since 4.3.0.
|
||||
*/
|
||||
export const PLACES_GOOGLE_ONLY_SETTING = 'places_google_only';
|
||||
|
||||
/**
|
||||
* Whoever holds the keyed slot beside the index for one request: Google's
|
||||
@@ -761,8 +767,14 @@ export class MapsService {
|
||||
|
||||
// ── Controller-facing surface (unchanged signatures) ───────────────────────
|
||||
|
||||
search(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<MapsSearchResult> {
|
||||
return this.searchPlaces(userId, query, lang, locationBias) as Promise<MapsSearchResult>;
|
||||
search(
|
||||
userId: number,
|
||||
query: string,
|
||||
lang?: string,
|
||||
locationBias?: { lat: number; lng: number; radius?: number },
|
||||
provider?: 'google',
|
||||
): Promise<MapsSearchResult> {
|
||||
return this.searchPlaces(userId, query, lang, locationBias, { googleOnly: provider === 'google' }) as Promise<MapsSearchResult>;
|
||||
}
|
||||
|
||||
autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias, sessionToken?: string): Promise<MapsAutocompleteResult> {
|
||||
@@ -1065,6 +1077,33 @@ export class MapsService {
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this search goes to Google and nowhere else.
|
||||
*
|
||||
* Two ways to ask for that, both born of the same moment: the index answered
|
||||
* a query with something that is not the place the traveller meant, and with
|
||||
* the index and OpenStreetMap answering first, Google was never consulted as
|
||||
* long as they found anything at all. The caller can send one search to
|
||||
* Google (`requested`, the "search Google instead" link under the results),
|
||||
* and the admin can make that the rule for every search and suggestion (the
|
||||
* switch beside the key). Either way it only holds when Google holds the key
|
||||
* slot: on an install without a Google key, or one that picked Amap or
|
||||
* OpenStreetMap, both change nothing, and the admin panel says so.
|
||||
*
|
||||
* Read after the keyed provider on purpose: that lookup already walked the
|
||||
* key chain, and a setting read ahead of it would shift the order of the
|
||||
* app_settings reads every test of the chain stubs by position.
|
||||
*/
|
||||
private googleOnly(keyed: KeyedProvider | null, requested = false): boolean {
|
||||
if (keyed?.id !== 'google') return false;
|
||||
if (requested) return true;
|
||||
const row = this.database.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?',
|
||||
PLACES_GOOGLE_ONLY_SETTING,
|
||||
);
|
||||
return row?.value === 'true';
|
||||
}
|
||||
|
||||
/** The Amap provider, when Amap holds the keyed slot; null otherwise. */
|
||||
resolvePlacesProvider(userId: number): AmapPlacesProvider | null {
|
||||
const keyed = this.keyedProvider(userId);
|
||||
@@ -2021,7 +2060,7 @@ export class MapsService {
|
||||
query: string,
|
||||
lang?: string,
|
||||
locationBias?: { lat: number; lng: number; radius?: number },
|
||||
opts: { googleIdentityOnly?: boolean } = {},
|
||||
opts: { googleIdentityOnly?: boolean; googleOnly?: boolean } = {},
|
||||
): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
const keyed = this.keyedProvider(userId);
|
||||
const { key: apiKey, source: keySource } = keyed?.id === 'google' ? keyed : { key: null, source: null };
|
||||
@@ -2048,7 +2087,10 @@ export class MapsService {
|
||||
// the same question twice. `null` means it never ran.
|
||||
let osmAnswer: Record<string, unknown>[] | null = null;
|
||||
|
||||
if (this.trekPlacesEnabled() && !(opts.googleIdentityOnly && apiKey)) {
|
||||
// A search sent to Google on purpose, or the admin's "Google only" switch,
|
||||
// skips the pair the same way: the search then reads exactly as it did
|
||||
// before 4.3.0 on an install with a key.
|
||||
if (this.trekPlacesEnabled() && !(opts.googleIdentityOnly && apiKey) && !this.googleOnly(keyed, opts.googleOnly)) {
|
||||
// Both at once. The index is a dataset of businesses and is very good
|
||||
// at those; OpenStreetMap is where the temples, bridges, riverside
|
||||
// walks and viewpoints are, and a travel search asks for those
|
||||
@@ -2188,8 +2230,9 @@ export class MapsService {
|
||||
// could get every instance blocked at once. The index removes that.
|
||||
//
|
||||
// Same contract as search: never throws upward, falls through to what this
|
||||
// method did before.
|
||||
if (this.trekPlacesEnabled()) {
|
||||
// method did before. The admin's "Google only" switch skips the index here
|
||||
// too, so the suggestions and the search agree on where they come from.
|
||||
if (this.trekPlacesEnabled() && !this.googleOnly(keyed)) {
|
||||
try {
|
||||
const centre = locationBias
|
||||
? {
|
||||
|
||||
@@ -182,6 +182,11 @@ export class OidcController {
|
||||
}
|
||||
|
||||
this.oidc.touchLastLogin(result.user.id);
|
||||
// The login row every other method writes (#2417). Here rather than at
|
||||
// /exchange, because this is where the provider has vouched for the user
|
||||
// and where the client IP is, the same place the role change is recorded;
|
||||
// `method` names the way in, as the passkey login does.
|
||||
this.audit.writeAudit({ userId: result.user.id, action: 'user.login', ip: getClientIp(req), details: { method: 'oidc' } });
|
||||
// Pass the flag through untouched: `undefined` must reach the token as
|
||||
// "absent", not `false`, or the sliding renewal would later downgrade the
|
||||
// default persistent cookie to a browser-session one (remember-me, #1927).
|
||||
|
||||
@@ -511,7 +511,7 @@ export class PackingMcp {
|
||||
description: 'Import multiple packing items at once from a list. Optionally assign each to a bag (by name — created if missing), set its weight, or pre-check it.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
items: z.array(z.object({
|
||||
items: z.array(z.strictObject({
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().optional(),
|
||||
quantity: z.number().int().positive().optional(),
|
||||
|
||||
@@ -306,18 +306,19 @@ export class PlacesMcp {
|
||||
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'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'places', mode: 'read' },
|
||||
})
|
||||
async searchPlace(
|
||||
{ query, locationBias, lang }: {
|
||||
query: string; locationBias?: { lat: number; lng: number; radius?: number }; lang?: string;
|
||||
{ query, locationBias, lang, provider }: {
|
||||
query: string; locationBias?: { lat: number; lng: number; radius?: number }; lang?: string; provider?: 'google';
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
try {
|
||||
const result = await this.maps.searchPlaces(ctx.userId, query, lang, locationBias);
|
||||
const result = await this.maps.searchPlaces(ctx.userId, query, lang, locationBias, { googleOnly: provider === 'google' });
|
||||
return ok(result);
|
||||
} catch {
|
||||
return errorResult('Place search failed.');
|
||||
|
||||
@@ -35,7 +35,7 @@ export class PluginSearchMcp {
|
||||
description: 'Search for a place in the search indexes installed plugins provide, which are the ones TREK does not ship itself. Use it alongside search_place, never instead of it: search_place is TREK\'s own index and OpenStreetMap, this is whatever else the instance owner installed, and only these results can carry a rating — open data has none, so a question like "the best rated hotel near here" can only be answered from this list. Results have the same shape search_place returns, plus a `rating` and the `pluginId` that found them. Returns an empty list when no plugin provides a search index, which is the normal case.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(MAX_QUERY).describe('Place name or address to search for'),
|
||||
near: z.object({ lat: z.number(), lng: z.number() })
|
||||
near: z.strictObject({ lat: z.number(), lng: z.number() })
|
||||
.optional()
|
||||
.describe('Centre the search on a coordinate. Pass it whenever the trip has a destination: a bare name like "Central Station" otherwise resolves wherever the provider guesses'),
|
||||
lang: z.string().max(20).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja"'),
|
||||
|
||||
@@ -47,7 +47,7 @@ const urlField = reservationUrlSchema.max(2000).optional()
|
||||
type TransportType = typeof CREATABLE_TRANSPORT_TYPES[number];
|
||||
type BookingType = typeof BOOKING_TYPES[number];
|
||||
|
||||
const endpointObjectSchema = z.object({
|
||||
const endpointObjectSchema = z.strictObject({
|
||||
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
|
||||
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
|
||||
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
|
||||
@@ -520,7 +520,7 @@ export class ReservationsMcp {
|
||||
description: 'Update the display order of reservations within a day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
positions: z.array(z.object({
|
||||
positions: z.array(z.strictObject({
|
||||
id: z.number().int().positive(),
|
||||
day_plan_position: z.number().int().min(0),
|
||||
})).describe('Array of { id, day_plan_position } pairs'),
|
||||
|
||||
@@ -846,6 +846,7 @@ export class ReservationsService {
|
||||
if (start_day_id && end_day_id) {
|
||||
this.requireResolvableStay(create_accommodation);
|
||||
if (resolvedAccId) {
|
||||
const prior = this.db.get<{ check_in: string | null }>('SELECT check_in FROM day_accommodations WHERE id = ?', resolvedAccId);
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?',
|
||||
accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId
|
||||
@@ -853,7 +854,9 @@ export class ReservationsService {
|
||||
// The stay just moved. Its stop moves with it, or it is left sitting on a
|
||||
// day nobody sleeps there any more, hidden from the day list because it
|
||||
// still carries this booking's id and stranded in the middle of the drive.
|
||||
stayMirror = this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in);
|
||||
stayMirror = this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in, {
|
||||
checkInChanged: (check_in || null) !== (prior?.check_in ?? null),
|
||||
});
|
||||
} else if (accPlaceId) {
|
||||
const accResult = this.db.run(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Injectable, HttpException } from '@nestjs/common';
|
||||
import { type RoadtripPreferences } from '@trek/shared';
|
||||
import {
|
||||
assembleRoadtrip,
|
||||
carrierLegsFor,
|
||||
carrierSeam,
|
||||
foldRouteRun,
|
||||
splitIntoRuns,
|
||||
spillChains,
|
||||
@@ -16,12 +18,17 @@ import {
|
||||
parseAvoid,
|
||||
formatDistance,
|
||||
formatDurationShort,
|
||||
seatCarrierStops,
|
||||
viasLeaving,
|
||||
type CarrierBooking,
|
||||
type CarrierSeam,
|
||||
type DistanceUnit,
|
||||
type RoadtripStop,
|
||||
type PlanDay,
|
||||
type RoutedLeg,
|
||||
type SnappedWaypoint,
|
||||
type RouteAvoidClass,
|
||||
standsAsDay,
|
||||
} from '@trek/shared/roadtrip';
|
||||
|
||||
interface StoredDay {
|
||||
@@ -31,13 +38,32 @@ interface StoredDay {
|
||||
title: string | null;
|
||||
default_transport_mode: string | null;
|
||||
}
|
||||
/**
|
||||
* A booking the traveller rides, with what the seam needs of it: the days and clocks at
|
||||
* both ends, the terminals, and where the day plan seats it. Same projection the client
|
||||
* reads off the reservation list, so the two plans seat a flight in the same place.
|
||||
*/
|
||||
interface CarrierRow {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
day_id: number | null;
|
||||
end_day_id: number | null;
|
||||
reservation_time: string | null;
|
||||
reservation_end_time: string | null;
|
||||
metadata: string | null;
|
||||
day_plan_position: number | null;
|
||||
}
|
||||
interface VisitRow {
|
||||
/** The booking this stop stands for on its check-in day, when there is one. */
|
||||
stay_id: number | null;
|
||||
check_in: string | null;
|
||||
/** The drive does not read these two (#2357): they are the booking as get_roadtrip_context reports it. */
|
||||
check_out: string | null;
|
||||
checkout_day: number | null;
|
||||
id: number;
|
||||
day_id: number;
|
||||
order_index: number;
|
||||
place_id: number;
|
||||
name: string;
|
||||
lat: number | null;
|
||||
@@ -72,10 +98,10 @@ export class RoadtripPlanService {
|
||||
tripId,
|
||||
);
|
||||
const visits = this.db.all<VisitRow>(
|
||||
`SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng,
|
||||
`SELECT a.id, a.day_id, a.order_index, a.place_id, p.name, p.lat, p.lng,
|
||||
COALESCE(a.assignment_time, p.place_time) AS time, COALESCE(a.assignment_end_time, p.end_time) AS end_time,
|
||||
p.duration_minutes, a.end_day,
|
||||
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
|
||||
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
|
||||
FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id
|
||||
LEFT JOIN day_accommodations stay ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1)
|
||||
LEFT JOIN days checkout ON checkout.id = stay.end_day_id
|
||||
@@ -85,6 +111,7 @@ export class RoadtripPlanService {
|
||||
return {
|
||||
days,
|
||||
visits,
|
||||
carriers: this.carriers(tripId),
|
||||
settings: this.preferences.read(tripId),
|
||||
profiles: this.router.profiles(),
|
||||
vias: this.roadtrip.listForTrip(tripId),
|
||||
@@ -93,6 +120,48 @@ export class RoadtripPlanService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bookings that seam the drive, a flight, train, ferry, cruise or bus on a day, and
|
||||
* the hire cars whose desks stand on it, with their terminals and the slots the day
|
||||
* plan gave them. The reservation service reads the same three tables for the booking
|
||||
* list; this is the road trip's own cut of them, so the module stays out of the
|
||||
* reservations graph.
|
||||
*/
|
||||
private carriers(tripId: number): CarrierBooking[] {
|
||||
const rows = this.db.all<CarrierRow>(
|
||||
`SELECT id, type, title, day_id, end_day_id, reservation_time, reservation_end_time, metadata, day_plan_position
|
||||
FROM reservations WHERE trip_id = ? AND type IN ('flight', 'train', 'ferry', 'cruise', 'bus', 'car') AND day_id IS NOT NULL`,
|
||||
tripId,
|
||||
);
|
||||
if (!rows.length) return [];
|
||||
const endpoints = this.db.all<{
|
||||
reservation_id: number;
|
||||
role: string;
|
||||
sequence: number;
|
||||
name: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}>(
|
||||
`SELECT e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng
|
||||
FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id
|
||||
WHERE r.trip_id = ? ORDER BY e.reservation_id, e.sequence`,
|
||||
tripId,
|
||||
);
|
||||
const positions = this.db.all<{ reservation_id: number; day_id: number; position: number }>(
|
||||
`SELECT p.reservation_id, p.day_id, p.position
|
||||
FROM reservation_day_positions p JOIN reservations r ON r.id = p.reservation_id WHERE r.trip_id = ?`,
|
||||
tripId,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
endpoints: endpoints.filter((e) => e.reservation_id === row.id),
|
||||
day_positions: Object.fromEntries(
|
||||
positions.filter((p) => p.reservation_id === row.id).map((p) => [String(p.day_id), p.position]),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
async calculate(tripId: number, userId: number, overrides?: RoadtripPreferences) {
|
||||
const context = this.context(tripId, userId);
|
||||
const preferences = { ...context.settings, ...overrides };
|
||||
@@ -103,14 +172,12 @@ export class RoadtripPlanService {
|
||||
);
|
||||
if (preferences.roadtrip_day_start && preferences.roadtrip_day_end && !window)
|
||||
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
|
||||
const plan: PlanDay[] = context.days.map((day) => ({
|
||||
dayId: day.id,
|
||||
dayNumber: day.day_number,
|
||||
date: day.date,
|
||||
title: day.title,
|
||||
stops: context.visits
|
||||
.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null)
|
||||
.map((v, index) => ({
|
||||
const seams = context.carriers.map((booking) => carrierSeam(booking)).filter((seam): seam is CarrierSeam => seam !== null);
|
||||
const dayNumberOf = (dayId: number): number => context.days.find((d) => d.id === dayId)?.day_number ?? 0;
|
||||
const plan: PlanDay[] = context.days.map((day) => {
|
||||
const visits = context.visits.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null);
|
||||
const stops = visits.map(
|
||||
(v, index): RoadtripStop => ({
|
||||
assignmentId: v.id,
|
||||
ownerDayId: day.id,
|
||||
ownerIndex: index,
|
||||
@@ -123,14 +190,29 @@ export class RoadtripPlanService {
|
||||
// planner makes in the browser (useRoadtripRoutes).
|
||||
leaveAt: v.end_time,
|
||||
checkInTime: v.check_in,
|
||||
night: v.stay_id !== null,
|
||||
dwellMinutes: v.duration_minutes,
|
||||
endDay: v.end_day === 1,
|
||||
legMode: v.leg_transport_mode,
|
||||
incomingLegMode: v.incoming_leg_transport_mode,
|
||||
stopType: v.stop_type,
|
||||
fillPercent: v.fill_percent,
|
||||
})),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
return {
|
||||
dayId: day.id,
|
||||
dayNumber: day.day_number,
|
||||
date: day.date,
|
||||
title: day.title,
|
||||
// The terminals of the day's rides, seated where the day plan shows the booking.
|
||||
stops: seatCarrierStops(
|
||||
day.id,
|
||||
stops,
|
||||
visits.map((v) => v.order_index),
|
||||
seams,
|
||||
),
|
||||
};
|
||||
});
|
||||
const allLegs: Record<string, RoutedLeg> = {};
|
||||
const snapByDay: Record<number, Record<string, SnappedWaypoint>> = {};
|
||||
const missedByDay: Record<number, RouteAvoidClass[]> = {};
|
||||
@@ -143,21 +225,28 @@ export class RoadtripPlanService {
|
||||
const distanceUnit: DistanceUnit =
|
||||
this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric';
|
||||
const fetchRun = async (stops: RoadtripStop[], dayId: number, profile: string) => {
|
||||
const pairs = stops.slice(0, -1).map((from, index) => ({ from, to: stops[index + 1] }));
|
||||
pairs.forEach(({ from, to }) => asked.add(stopKey(from) + '>' + stopKey(to)));
|
||||
// A ride is no road: the leg between its terminals is the booking's minutes, and
|
||||
// the router is never asked for it.
|
||||
const rides = carrierLegsFor(
|
||||
stops,
|
||||
profile,
|
||||
(from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId),
|
||||
(from, to) => stopKey(from) + '>' + stopKey(to),
|
||||
);
|
||||
if (rides) {
|
||||
Object.assign(allLegs, rides);
|
||||
return;
|
||||
}
|
||||
const points: { lat: number; lng: number }[] = [];
|
||||
const stopAt: number[] = [];
|
||||
stops.forEach((stop, index) => {
|
||||
stopAt.push(points.length);
|
||||
points.push({ lat: stop.lat, lng: stop.lng });
|
||||
if (index < stops.length - 1)
|
||||
points.push(
|
||||
...context.vias
|
||||
.filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
.map((v) => ({ lat: v.lat, lng: v.lng })),
|
||||
);
|
||||
points.push(...viasLeaving(stop, context.vias).map((v) => ({ lat: v.lat, lng: v.lng })));
|
||||
});
|
||||
const pairs = stops.slice(0, -1).map((from, index) => ({ from, to: stops[index + 1] }));
|
||||
pairs.forEach(({ from, to }) => asked.add(stopKey(from) + '>' + stopKey(to)));
|
||||
try {
|
||||
if (points.length > 100) throw new Error('Too many waypoints');
|
||||
const routed = await this.router.route(userId, tripId, dayId, points, profile, avoid);
|
||||
@@ -245,8 +334,8 @@ export class RoadtripPlanService {
|
||||
preferences.roadtrip_range_km,
|
||||
) || null;
|
||||
const calculated = assembleRoadtrip({
|
||||
plan: plan.filter((d) => d.stops.length > 1),
|
||||
quietDays: plan.filter((d) => d.stops.length < 2),
|
||||
plan: plan.filter((d) => standsAsDay(d.stops)),
|
||||
quietDays: plan.filter((d) => !standsAsDay(d.stops)),
|
||||
window,
|
||||
allLegs,
|
||||
snapByDay,
|
||||
|
||||
@@ -28,7 +28,7 @@ export class RoadtripPlanningMcp {
|
||||
@Tool({
|
||||
name: 'get_roadtrip_context',
|
||||
description:
|
||||
'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
|
||||
'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, manual day endings and the carrier bookings (flight, train, ferry, cruise, bus) that seam the drive at their terminals, plus the hire cars whose pick-up and return desks stand on it. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
|
||||
inputSchema: { tripId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
@@ -41,7 +41,7 @@ export class RoadtripPlanningMcp {
|
||||
@Tool({
|
||||
name: 'calculate_roadtrip',
|
||||
description:
|
||||
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
|
||||
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
|
||||
inputSchema: roadtripPlanRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
|
||||
@@ -89,14 +89,14 @@ export class RoadtripMcp {
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
vias: z.array(z.object({
|
||||
vias: z.array(z.strictObject({
|
||||
after_order_index: z.number().int().min(0).describe('Which stop of the day this via follows, counting from 0'),
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
})).max(100).describe('In the order the drive passes through them'),
|
||||
replace_legs: z.array(z.number().int().min(0)).max(100).optional()
|
||||
.describe('Legs to clear before inserting, by the index of the stop they follow'),
|
||||
track: z.object({
|
||||
track: z.strictObject({
|
||||
place_id: z.number().int().positive().describe('The imported track this chain was fitted to, as its place id'),
|
||||
stray_km: z.number().min(0).max(40_000).nullable().optional()
|
||||
.describe('How far the fitted route still runs from the track at its worst point'),
|
||||
@@ -139,7 +139,7 @@ export class RoadtripMcp {
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
vias: z.array(z.object({
|
||||
vias: z.array(z.strictObject({
|
||||
id: z.number().int().positive(),
|
||||
after_order_index: z.number().int().min(0).describe('Which stop of the day the via now follows, counting from 0'),
|
||||
})).max(500),
|
||||
|
||||
@@ -88,7 +88,7 @@ export class TripsMcp {
|
||||
description: z.string().max(2000).optional().describe('Trip description'),
|
||||
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'),
|
||||
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
|
||||
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
|
||||
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD). Left out, the trip takes the display currency from the settings (see get_display_settings), or EUR when none is set.'),
|
||||
day_count: z.number().int().min(1).max(MAX_TRIP_DAYS).optional().describe(
|
||||
'How many days a trip without dates gets (default 7). Ignored when start_date and end_date are both set, because the range decides the count.'),
|
||||
reminder_days: z.number().int().min(0).max(30).optional().describe(
|
||||
|
||||
@@ -32,6 +32,7 @@ import { StorageModule } from '../storage/storage.module';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { buildStorageUploadOptions } from '../storage/storage-upload.factory';
|
||||
import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
|
||||
/** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does
|
||||
* not capture the nested sub-domain mounts (collab, files, ...). */
|
||||
@@ -48,7 +49,7 @@ import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller';
|
||||
}),
|
||||
}),
|
||||
StorageModule,
|
||||
McpSharedModule, TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule],
|
||||
McpSharedModule, TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule, SettingsModule],
|
||||
controllers: [TripsController],
|
||||
providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc],
|
||||
// Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection).
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ReservationsService } from '../reservations/reservations.service';
|
||||
import { VacayService } from '../vacay/vacay.service';
|
||||
import { UnsplashService } from '../unsplash/unsplash.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { NotFoundError, ValidationError } from '../common/domain-errors';
|
||||
|
||||
export const MS_PER_DAY = 86400000;
|
||||
@@ -134,15 +135,16 @@ export interface GuestMember {
|
||||
/**
|
||||
* Trip aggregate root, DI-native. Membership, the calendar export and the two
|
||||
* read aggregates live in their own domains now; what is left is the write core
|
||||
* plus day generation, which is why the constructor is eight parameters instead
|
||||
* of fourteen. The SQL moved 1:1 from the legacy
|
||||
* plus day generation, which is why the constructor is far shorter than the
|
||||
* fourteen parameters it started with. The SQL moved 1:1 from the legacy
|
||||
* services/tripService.ts: identical statements, the `||` falsy-coercion
|
||||
* defaults, the post-write TRIP_SELECT re-selects and the mixed
|
||||
* named/positional parameter styles are all preserved byte-for-byte.
|
||||
* Post-migration quirk fixes on top of the 1:1 move: the multi-statement
|
||||
* deletes (remove, deleteGuest's re-split + user delete) run in
|
||||
* db.transaction(), and listMembers' owner row COALESCEs display_name like
|
||||
* the member rows. Auth (canAccessTrip), per-field permission checks and
|
||||
* db.transaction(), listMembers' owner row COALESCEs display_name like
|
||||
* the member rows, and create() defaults the currency to the owner's display
|
||||
* currency instead of the legacy EUR literal. Auth (canAccessTrip), per-field permission checks and
|
||||
* audit logging stay in the controller (1:1 with the legacy route);
|
||||
* trip:updated / trip:deleted broadcasts stay in the controller too — this
|
||||
* service emits none.
|
||||
@@ -159,12 +161,24 @@ export class TripsService {
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly unsplash: UnsplashService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* The currency a trip gets when the caller names none: the person's display
|
||||
* currency, admin default merged in, else EUR. The trip dialog pre-fills the
|
||||
* same value on the client, so a trip created through MCP or a plugin lands
|
||||
* in the currency the person reads amounts in instead of always in EUR.
|
||||
*/
|
||||
private defaultCurrencyFor(userId: number): string {
|
||||
const preferred = this.settings.getUserSettings(userId).default_currency;
|
||||
return typeof preferred === 'string' && preferred.trim() ? preferred.trim() : 'EUR';
|
||||
}
|
||||
|
||||
canAccessTrip(tripId: string | number, userId: number) {
|
||||
return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined;
|
||||
}
|
||||
@@ -328,7 +342,7 @@ export class TripsService {
|
||||
const result = this.db.prepare(`
|
||||
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd);
|
||||
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || this.defaultCurrencyFor(userId), rd);
|
||||
|
||||
const tripId = result.lastInsertRowid;
|
||||
this.generateDays(tripId, data.start_date || null, data.end_date || null, data.day_count);
|
||||
|
||||
@@ -101,6 +101,9 @@ const { db } = vi.hoisted(() => {
|
||||
// StorageRegistryService (behind StorageModule, now in this module chain) reads
|
||||
// this at onModuleInit.
|
||||
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
||||
// A trip created without a currency takes the owner's display currency, read
|
||||
// off the per-user settings rows.
|
||||
tmp.exec('CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, UNIQUE(user_id, key));');
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
@@ -160,6 +163,7 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
|
||||
db.prepare('DELETE FROM trip_members').run();
|
||||
db.prepare('DELETE FROM days').run();
|
||||
db.prepare('DELETE FROM audit_log').run();
|
||||
db.prepare('DELETE FROM settings').run();
|
||||
canAccessTrip.mockReturnValue({ user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
@@ -198,6 +202,16 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(forbidden.status).toBe(403);
|
||||
});
|
||||
|
||||
it('201 create without a currency takes the display currency from the settings', async () => {
|
||||
db.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'default_currency', ?)").run(JSON.stringify('USD'));
|
||||
const preferred = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'Road trip' });
|
||||
expect(preferred.status).toBe(201);
|
||||
expect(preferred.body.trip).toMatchObject({ title: 'Road trip', currency: 'USD' });
|
||||
const explicit = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'Tokyo', currency: 'JPY' });
|
||||
expect(explicit.status).toBe(201);
|
||||
expect(explicit.body.trip).toMatchObject({ title: 'Tokyo', currency: 'JPY' });
|
||||
});
|
||||
|
||||
it('201 create keeps every day of a trip longer than a year (#2403)', async () => {
|
||||
// 2025-01-26 .. 2026-01-28 is 368 days; the day list used to stop at 365.
|
||||
const res = await request(server).post('/api/trips').set('Cookie', sessionCookie(1))
|
||||
|
||||
@@ -186,6 +186,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
realtimeService,
|
||||
new UnsplashService(dbService, new RuntimeEnvService(), generalStorage),
|
||||
generalStorage,
|
||||
new SettingsService(dbService),
|
||||
);
|
||||
const readModelService = new TripReadModelService(
|
||||
dbService, membersService, daysService, accommodationsService, budgetService,
|
||||
|
||||
@@ -113,7 +113,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
|
||||
const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs, photoCache), queryHelpers, unsplash, photoCache, journey, generalStorage, accommodations);
|
||||
// After accommodations: a hotel booking writes the stay's day stop through it.
|
||||
const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations);
|
||||
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage);
|
||||
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage, new SettingsService(dbs));
|
||||
const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget), permissions, realtime, notificationsStub());
|
||||
const guards = new PluginGuards(dbs, permissions, addons);
|
||||
|
||||
|
||||
@@ -190,6 +190,27 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
expect(res.headers.location).toContain('/login?oidc_code=');
|
||||
});
|
||||
|
||||
it('OIDC-004b: a successful login writes the user.login row every other method writes (#2417)', async () => {
|
||||
const { user } = createUser(testDb, { email: 'audited@example.com' });
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'test-access-token', id_token: 'fake.id.token', _ok: true, _status: 200 });
|
||||
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-audited-1' } });
|
||||
mockGetUserInfo.mockResolvedValueOnce({ sub: 'sub-audited-1', email: 'audited@example.com', name: 'Audited', email_verified: true });
|
||||
const { state } = oidcSvc.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
// The query from the report, against the same table.
|
||||
const rows = testDb.prepare('SELECT user_id, action, details FROM audit_log WHERE user_id = ? ORDER BY id DESC').all(user.id) as
|
||||
{ user_id: number; action: string; details: string | null }[];
|
||||
expect(rows.map(r => r.action)).toContain('user.login');
|
||||
const login = rows.find(r => r.action === 'user.login')!;
|
||||
expect(JSON.parse(login.details || '{}')).toEqual({ method: 'oidc' });
|
||||
const counted = testDb.prepare('SELECT login_count FROM users WHERE id = ?').get(user.id) as { login_count: number };
|
||||
expect(counted.login_count).toBe(1);
|
||||
});
|
||||
|
||||
it('OIDC-005: new user gets created when registration is open', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', id_token: 'fake.id.token', _ok: true, _status: 200 });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user