mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed8acf4c4b | |||
| a280517ff3 | |||
| 2dd77aaac1 | |||
| 85b5756e89 | |||
| cb85ba915c | |||
| 81292d4d2b | |||
| 757967bf18 | |||
| b0c19bfac1 | |||
| da32eb8a3c | |||
| a44df6cf11 | |||
| e510ff25ab | |||
| 34f3aeee5f | |||
| bdd326163f | |||
| b1baf3baa9 | |||
| 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
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.3.0
|
||||
version: 4.3.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.3.0"
|
||||
appVersion: "4.3.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.0",
|
||||
"version": "4.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
};
|
||||
|
||||
+12
-10
@@ -284,7 +284,7 @@ function RouteFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
@@ -301,7 +301,7 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
@@ -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
|
||||
@@ -360,6 +359,9 @@ export default function App() {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Last, so the version block above keeps its lines: the taint analyser
|
||||
// re-raises its browser-storage finding for every line that moves under it.
|
||||
if (config?.places_provider) setPlacesProvider(config.places_provider)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -1214,14 +1216,19 @@ export const mapsApi = {
|
||||
* caller of this one function gets them without knowing they exist. Appended rather
|
||||
* than interleaved: the core list is ordered by relevance and has earned that order,
|
||||
* and a plugin's row carries its own `source` for a caller that wants to mark it.
|
||||
*
|
||||
* `provider: 'google'` sends this one search to Google alone, the "search Google
|
||||
* instead" link under a list the index answered with the wrong place. The server
|
||||
* ignores it unless Google holds the keyed slot: without a Google key, or with
|
||||
* Amap or OpenStreetMap picked as the provider, the index answers as usual.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008
|
||||
// FE-COMP-LINKS-001 to FE-COMP-LINKS-013
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -10,7 +10,9 @@ vi.mock('../../api/websocket', () => ({
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -63,7 +65,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 +121,106 @@ 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-011: deleting asks first, and the DELETE goes out only once confirmed', async () => {
|
||||
// The link goes for every member, and on the phone the button is a thumb's
|
||||
// width from pin: the notes panel guards the same way.
|
||||
const user = userEvent.setup();
|
||||
let deleted = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
|
||||
http.delete('/api/trips/1/collab/links/1', () => { deleted = true; return HttpResponse.json({ success: true }); }),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
await user.click(await screen.findByRole('button', { name: /delete link|collab\.links\.delete/i }));
|
||||
expect(await screen.findByText(/delete link\?|collab\.links\.confirmDeleteTitle/i)).toBeInTheDocument();
|
||||
expect(deleted).toBe(false);
|
||||
expect(screen.getByText('Ferry timetable')).toBeInTheDocument();
|
||||
|
||||
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
|
||||
await waitFor(() => expect(deleted).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText('Ferry timetable')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-012: a corrected address gets a fresh favicon attempt', async () => {
|
||||
// The icon remembers a favicon that failed, and an edit keeps the chip's id,
|
||||
// so the corrected address kept the generic glyph until the panel remounted.
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink({ url: 'https://old.example/page' })] })),
|
||||
http.put('/api/trips/1/collab/links/1', () => HttpResponse.json({ link: buildLink({ url: 'https://new.example/page' }) })),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
const chip = await screen.findByRole('link', { name: /ferry timetable/i });
|
||||
fireEvent.error(chip.querySelector('img') as HTMLImageElement);
|
||||
await waitFor(() => expect(chip.querySelector('img')).toBeNull());
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /edit link|collab\.links\.edit/i }));
|
||||
const url = await screen.findByLabelText(/https|collab\.links\.urlPlaceholder/i);
|
||||
await user.clear(url);
|
||||
await user.type(url, 'https://new.example/page');
|
||||
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('link', { name: /ferry timetable/i }).querySelector('img'))
|
||||
.toHaveAttribute('src', 'https://new.example/favicon.ico'));
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-013: on touch the chip actions are finger-sized and apart', () => {
|
||||
// jsdom lays nothing out, so the rule is the assertion. The phone Collab tab
|
||||
// mounts this chip; 22px buttons a pixel apart put delete one slip from pin.
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8');
|
||||
const at = css.indexOf('@media (hover: none) {\n .collab-link-chip__actions');
|
||||
expect(at, 'touch rules for the chip actions missing from index.css').toBeGreaterThan(-1);
|
||||
const touch = css.slice(at, css.indexOf('\n}', at));
|
||||
expect(touch).toMatch(/\.collab-link-chip__actions \{[^}]*gap:\s*4px/);
|
||||
const action = /\.collab-link-chip__action \{([^}]*)\}/.exec(touch)?.[1] ?? '';
|
||||
for (const side of ['width', 'height']) {
|
||||
expect(Number(new RegExp(`${side}:\\s*(\\d+)px`).exec(action)?.[1]), side).toBeGreaterThanOrEqual(24);
|
||||
}
|
||||
// The height comes back through the margin, so the chip itself stays 36px.
|
||||
expect(action).toMatch(/margin:\s*-4px 0/);
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => {
|
||||
// collab_edit reserved for the owner, on somebody else's trip.
|
||||
seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } });
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
@@ -18,23 +19,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 +55,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 +78,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 +111,8 @@ 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)
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null)
|
||||
|
||||
// Both are new objects on every render, so depending on them would reload the
|
||||
// list after each keystroke and drop a link that was just added back out of it.
|
||||
@@ -168,6 +182,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 +222,56 @@ 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')}>
|
||||
{/* Keyed on the address: the icon remembers a favicon that failed, and
|
||||
an edit keeps the chip's id, so a corrected address got the glyph. */}
|
||||
<LinkIcon key={link.url} url={link.url} />
|
||||
<span className="collab-link-chip__text">
|
||||
<span className="collab-link-chip__title">{link.title}</span>
|
||||
<span className="collab-link-chip__host">{hostOf(link.url)}</span>
|
||||
</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={() => setPendingDeleteId(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)} />}
|
||||
|
||||
{/* The delete is shared: the link goes for every member, and on the phone the
|
||||
button is a thumb's width from pin. Same guard as the notes panel. */}
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDeleteId !== null}
|
||||
onClose={() => setPendingDeleteId(null)}
|
||||
onConfirm={() => (pendingDeleteId !== null ? remove(pendingDeleteId) : undefined)}
|
||||
title={t('collab.links.confirmDeleteTitle')}
|
||||
message={t('collab.links.confirmDeleteBody')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-040
|
||||
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-059
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
@@ -1028,4 +1028,47 @@ 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()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-058: the gallery picker gives a clip without a poster the same play badge (#2341)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const clip = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: null }
|
||||
const { container } = mountEditor(buildEntry(), { galleryPhotos: [clip] })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
|
||||
|
||||
expect(container.querySelector('img[src="/api/photos/200/thumbnail"]')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-059: a clip whose poster fails to load is not retried as the clip itself', async () => {
|
||||
// The /original of a video is the video file, which an <img> cannot draw.
|
||||
const user = userEvent.setup()
|
||||
const strip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/a.jpg' }
|
||||
const picker = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: 'journey/b.jpg' }
|
||||
const { container } = mountEditor(buildEntry({ id: 10, photos: [strip] }), { galleryPhotos: [picker] })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
|
||||
for (const id of [100, 200]) {
|
||||
const img = container.querySelector(`img[src="/api/photos/${id}/thumbnail"]`) as HTMLImageElement
|
||||
fireEvent.error(img)
|
||||
expect(img.getAttribute('src')).toBe(`/api/photos/${id}/thumbnail`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useEffect, useMemo, useState, useRef, type SyntheticEvent } from 'react'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } 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'
|
||||
@@ -21,6 +21,27 @@ import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourn
|
||||
|
||||
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
|
||||
|
||||
// A photo whose thumbnail is missing can still be drawn from its original. A
|
||||
// clip's original is the video file, which no <img> can show, so a clip gets
|
||||
// no fallback (#2341).
|
||||
function thumbnailFallback(p: { photo_id: number; media_type?: string | null }) {
|
||||
if (p.media_type === 'video') return undefined
|
||||
return (e: SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget
|
||||
if (!img.src.includes('/original')) img.src = photoUrl(p, 'original')
|
||||
}
|
||||
}
|
||||
|
||||
// No poster to show and falling back to /original would hand an <img> the clip
|
||||
// itself, so this tile stays a play badge.
|
||||
function ClipTile() {
|
||||
return (
|
||||
<div className="absolute inset-0 bg-black flex items-center justify-center text-white">
|
||||
<Play size={18} className="ml-0.5" fill="currentColor" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
|
||||
entry: JourneyEntry
|
||||
journeyId: number
|
||||
@@ -434,7 +455,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
className="relative block w-full rounded-xl overflow-hidden border-0 p-0 bg-transparent cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
|
||||
style={{ paddingTop: '100%' }}
|
||||
>
|
||||
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
{posterlessVideo(gp) ? (
|
||||
<ClipTile />
|
||||
) : (
|
||||
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={thumbnailFallback(gp)} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{availableGalleryPhotos.length === 0 && (
|
||||
@@ -527,7 +552,11 @@ 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) ? (
|
||||
<ClipTile />
|
||||
) : (
|
||||
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={thumbnailFallback(p)} />
|
||||
)}
|
||||
{idx === 0 && photos.length > 1 && (
|
||||
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-COVER-001 to FE-JRN-COVER-008
|
||||
// FE-JRN-COVER-001 to FE-JRN-COVER-010
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -112,4 +112,23 @@ describe('JourneyEntryCover', () => {
|
||||
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-009: a clip without a poster falls back to the ground, marked as a clip (#2341)', () => {
|
||||
// Its thumbnail route 404s on purpose, so an <img> would have been a broken face
|
||||
// across the whole card.
|
||||
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: null }] })
|
||||
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
|
||||
|
||||
expect(container.querySelector('img[src*="/thumbnail"]')).toBeNull()
|
||||
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg.lucide-map-pin')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-010: a clip with its poster fills the card like any photo', () => {
|
||||
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }] })
|
||||
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
|
||||
expect(container.querySelector('svg.lucide-play')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MapPin, Plus } from 'lucide-react'
|
||||
import { MapPin, Play, Plus } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
|
||||
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import CountryFlag from '../shared/CountryFlag'
|
||||
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
@@ -25,6 +26,13 @@ import type { JourneyEntry } from '../../store/journeyStore'
|
||||
* which needs no tokens at all.
|
||||
*/
|
||||
|
||||
/** A card's photo, plus what says whether a clip has a poster to draw (#2341). */
|
||||
export interface CoverPhoto extends CardPhoto {
|
||||
media_type?: string | null
|
||||
provider?: string | null
|
||||
thumbnail_path?: string | null
|
||||
}
|
||||
|
||||
/** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */
|
||||
export interface CoverEntry {
|
||||
id: number
|
||||
@@ -39,7 +47,7 @@ export interface CoverEntry {
|
||||
entry_time?: string | null
|
||||
mood?: string | null
|
||||
weather?: string | null
|
||||
photos?: CardPhoto[]
|
||||
photos?: CoverPhoto[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -69,8 +77,12 @@ export default function JourneyEntryCover({
|
||||
const { t, locale } = useTranslation()
|
||||
const isSuggestion = entry.type === 'skeleton'
|
||||
|
||||
const photoId = cardPhotoId(entry.photos?.[0])
|
||||
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
|
||||
const first = entry.photos?.[0]
|
||||
const photoId = cardPhotoId(first)
|
||||
// A clip without a poster has no picture to fill the card with; its thumbnail
|
||||
// route 404s and an <img> pointed at it would draw the broken glyph (#2341).
|
||||
const posterless = first != null && posterlessVideo(first)
|
||||
const src = photoId == null || posterless ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
|
||||
|
||||
const title = cardTitle(entry, t)
|
||||
const place = cardPlace(entry)
|
||||
@@ -112,7 +124,9 @@ export default function JourneyEntryCover({
|
||||
<img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full rounded-[18px] object-cover" />
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />
|
||||
{posterless
|
||||
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={{ color: dayColor }} className="ml-0.5 opacity-60" />
|
||||
: <MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017
|
||||
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
@@ -234,4 +234,25 @@ describe('MobileEntryView', () => {
|
||||
expect(screen.queryByText('Amazing')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Sunny')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYVIEW-019: a clip is drawn from its poster, in the hero as in the strip (#2341)', () => {
|
||||
// Its original is the video file, which an <img> cannot show, so the hero used
|
||||
// to be a broken image for every clip, poster or not.
|
||||
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }
|
||||
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images[0]).toHaveAttribute('src', '/api/photos/4/thumbnail')
|
||||
expect(images[1]).toHaveAttribute('src', '/api/photos/4/thumbnail')
|
||||
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYVIEW-020: a clip without a poster keeps the play badge and asks for no image at all', () => {
|
||||
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: null }
|
||||
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
|
||||
const images = container.querySelectorAll('img')
|
||||
// The second photo's strip thumbnail is the only image left.
|
||||
expect(images).toHaveLength(1)
|
||||
expect(images[0]).toHaveAttribute('src', '/api/photos/5/thumbnail')
|
||||
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import JournalBody from './JournalBody'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
// Labels are translation keys, like the desktop tables in
|
||||
@@ -105,12 +106,18 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
}`}
|
||||
onClick={() => onPhotoClick(photos, 0)}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
|
||||
alt=""
|
||||
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
{/* A clip is drawn from its poster, its original being the video file.
|
||||
Without a poster the black ground and the play badge are the hero (#2341). */}
|
||||
{posterlessVideo(photos[0]) ? (
|
||||
<span className="block aspect-video w-full max-h-[50vh]" />
|
||||
) : (
|
||||
<img
|
||||
src={photoUrl(photos[0], photos[0].media_type === 'video' ? 'thumbnail' : 'original', publicPhotoUrl)}
|
||||
alt=""
|
||||
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{photos[0].media_type === 'video' && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
@@ -140,12 +147,14 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
}`}
|
||||
onClick={() => onPhotoClick(photos, i)}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||
alt=""
|
||||
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
{!posterlessVideo(p) && (
|
||||
<img
|
||||
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||
alt=""
|
||||
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{p.media_type === 'video' && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const plyrCtor = vi.fn()
|
||||
vi.mock('plyr', () => ({
|
||||
default: class {
|
||||
el: HTMLElement
|
||||
original: Node
|
||||
constructor(el: HTMLElement, options: Record<string, unknown>) {
|
||||
plyrCtor(el, options)
|
||||
this.el = el
|
||||
this.original = el.cloneNode(true)
|
||||
}
|
||||
// What the real destroy() does to the document: the element as it was at
|
||||
// construction goes back in, whatever React wrote to it since.
|
||||
destroy() {
|
||||
this.el.replaceWith(this.original)
|
||||
}
|
||||
},
|
||||
}))
|
||||
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 out of the document by then.
|
||||
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')
|
||||
})
|
||||
|
||||
it('stepping to another clip builds the player on a fresh element, so the clip on screen is the new one', () => {
|
||||
// Rebuilding Plyr on the same <video> ran after React had already pointed it at
|
||||
// the next clip, and destroy() then swapped the first clip back in. The file
|
||||
// lightboxes do not key the player themselves, so this has to hold here.
|
||||
const { container, rerender } = render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
|
||||
rerender(<VideoPlayer src="/api/photos/8/original" autoPlay={false} />)
|
||||
|
||||
expect(plyrCtor).toHaveBeenCalledTimes(2)
|
||||
const [first] = plyrCtor.mock.calls[0]
|
||||
const [second] = plyrCtor.mock.calls[1]
|
||||
expect(second).not.toBe(first)
|
||||
expect(second).toBe(container.querySelector('video'))
|
||||
expect(container.querySelector('video')).toHaveAttribute('src', '/api/photos/8/original')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,18 @@
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
src: string
|
||||
poster?: string
|
||||
autoPlay?: boolean
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Video player for gallery/lightbox playback (#823), built on Plyr over a native
|
||||
@@ -8,18 +20,17 @@ import 'plyr/dist/plyr.css'
|
||||
* the source carries the correct video MIME from the server. The Plyr instance is
|
||||
* created once per mounted source and destroyed on unmount, so navigating away in
|
||||
* the lightbox stops playback.
|
||||
*
|
||||
* A new source is a new element: Plyr's destroy() puts a clone of the element as
|
||||
* it was at construction back into the document, so rebuilding the player on a
|
||||
* <video> React had already pointed at the next clip left the first clip on
|
||||
* screen under the next one's name.
|
||||
*/
|
||||
export default function VideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
autoPlay = true,
|
||||
style,
|
||||
}: {
|
||||
src: string
|
||||
poster?: string
|
||||
autoPlay?: boolean
|
||||
style?: React.CSSProperties
|
||||
}): React.ReactElement {
|
||||
export default function VideoPlayer(props: Props): React.ReactElement {
|
||||
return <PlyrVideo key={props.src} {...props} />
|
||||
}
|
||||
|
||||
function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactElement {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,6 +51,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 out of the document
|
||||
// by then, so the error it would otherwise report has nowhere to show.
|
||||
blankVideo: '',
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -49,7 +66,7 @@ export default function VideoPlayer({
|
||||
/* already torn down */
|
||||
}
|
||||
}
|
||||
}, [src, autoPlay])
|
||||
}, [autoPlay])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1030,6 +1030,26 @@ describe('MapView live location', () => {
|
||||
expect(geoMock.cycleMode).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-064b: turning the phone sideways and back takes the button with it', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
|
||||
geoMock.mode = 'off'
|
||||
render(<MapView />)
|
||||
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
|
||||
|
||||
// landscape crosses the 768px cut without remounting the map
|
||||
act(() => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 844 })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: 'Show my location' })).toBeNull()
|
||||
|
||||
act(() => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-077: the map draws no credit control of its own, on either width', () => {
|
||||
const desktop = render(<MapView />)
|
||||
// The desktop credit is Leaflet's own, in its own container; this component adds none.
|
||||
|
||||
@@ -547,6 +547,7 @@ import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } f
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
|
||||
// Live-location rendering inside the Leaflet map. Subscribes via the
|
||||
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
|
||||
@@ -1033,7 +1034,10 @@ export const MapView = memo(function MapView({
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
// The width is followed rather than read once: a phone turned sideways and
|
||||
// back crosses the breakpoint twice, and the button used to stay with
|
||||
// whatever the map saw when it mounted.
|
||||
const isMobile = useIsPhone()
|
||||
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
|
||||
// height var(--day-panel-h)) and covers the button's band, so lift the button
|
||||
// above it; otherwise keep the plain bottom-nav offset. #1348
|
||||
|
||||
@@ -22,6 +22,7 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Day, Place, Reservation, RouteVia } from '../../types'
|
||||
import type { MapHoverInfo } from './mapHover'
|
||||
@@ -1159,6 +1160,12 @@ export function MapViewGL({
|
||||
const onMapReadyRef = useRef(onMapReady)
|
||||
onMapReadyRef.current = onMapReady
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy), so
|
||||
// the location button would be misleading; the phone, where real GPS lives,
|
||||
// keeps it. Read here with the other hooks rather than beside the button:
|
||||
// the tokenless branch below returns early, and a hook after it runs on some
|
||||
// renders and not others.
|
||||
const isMobile = useIsPhone()
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
onClickRefs.current.map = onMapClick
|
||||
@@ -2382,9 +2389,6 @@ export function MapViewGL({
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
|
||||
// height var(--day-panel-h)) and covers the button's band, so lift the button
|
||||
// above it; otherwise keep the plain bottom-nav offset. #1348
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -31,6 +31,12 @@ function getIframe(): HTMLIFrameElement | null {
|
||||
return document.querySelector('#pdf-preview-overlay iframe')
|
||||
}
|
||||
|
||||
/** The cover's "Planned" figure. The stat is read off its label, since days and places print the same markup. */
|
||||
function plannedStat(html: string): number | null {
|
||||
const match = /<div class="cover-stat-num">(\d+)<\/div>\s*<div class="cover-stat-lbl">pdf\.planned<\/div>/.exec(html)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -253,6 +259,51 @@ 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, price: '120' } as any
|
||||
const hotelStop = { id: 201, day_id: 10, place_id: 101, order_index: 0, accommodation_id: 30, place: hotelPlace }
|
||||
const flight = { ...transportReservation, title: 'Morning flight', reservation_time: '2025-06-01T08:00:00' }
|
||||
await downloadTripPDF({
|
||||
...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)
|
||||
// The cover and the day header read the same list: the hidden stop is not a planned
|
||||
// stop and its hotel's price is not part of the day, whichever shell asked.
|
||||
expect(plannedStat(html)).toBe(1)
|
||||
const money = html.replace(/[\u00a0\u202f]/g, ' ')
|
||||
expect(money).toContain('15,00 €')
|
||||
expect(money).not.toContain('135,00 €')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-013g: a service stop the plan keeps to the road trip view is not printed either, and comes back with the switch', async () => {
|
||||
// "Show in Days too" off keeps petrol stations and rest areas out of the day lists.
|
||||
// The print follows the same switch, so the desktop, which hands over the store's
|
||||
// assignments, and the phone print the same day.
|
||||
const pump = { id: 102, name: 'Aral Autohof', address: 'Autobahnkreuz 1', stop_type: 'fuel', place_time: null } as any
|
||||
const pumpStop = { id: 202, day_id: 10, place_id: 102, order_index: 1, place: pump }
|
||||
const assignments = { '10': [assignmentForDay, pumpStop] } as any
|
||||
await downloadTripPDF({ ...richArgs, assignments, showServiceStops: false })
|
||||
const hidden = getIframe()!.srcdoc
|
||||
expect(hidden).not.toContain('Aral Autohof')
|
||||
expect(hidden).toContain('Colosseum')
|
||||
expect(plannedStat(hidden)).toBe(1)
|
||||
|
||||
// On, and by default, the stop prints where the plan lists it.
|
||||
getOverlay()?.remove()
|
||||
await downloadTripPDF({ ...richArgs, assignments })
|
||||
const shown = getIframe()!.srcdoc
|
||||
expect(shown).toContain('Aral Autohof')
|
||||
expect(plannedStat(shown)).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-013c: a flight that lands the same day shows both times (#1310)', async () => {
|
||||
const sameDay = { ...transportReservation, reservation_end_time: '2025-06-01T16:45:00' }
|
||||
await downloadTripPDF({ ...richArgs, reservations: [sameDay] })
|
||||
|
||||
@@ -16,6 +16,7 @@ import { renderTripMapImage } from './tripMapImage'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { fetchExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
|
||||
import { isServiceStopType } from '../Roadtrip/roadtripModel'
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
@@ -199,11 +200,34 @@ interface downloadTripPDFProps {
|
||||
timeFormat?: string
|
||||
/** 'metric' | 'imperial'. Same reasoning as `timeFormat` — read as a prop, not a hook. */
|
||||
distanceUnit?: string
|
||||
/**
|
||||
* The road trip setting "Show in Days too" (`roadtrip_service_stops_in_days`), read as a
|
||||
* prop for the same reason. Off, the day plan keeps petrol stations and rest areas to
|
||||
* the road trip view, and so does the print.
|
||||
*/
|
||||
showServiceStops?: boolean
|
||||
}
|
||||
|
||||
// `assignments` is normalised here once — every read below (and fetchPlacePhotos)
|
||||
// relies on it being an object.
|
||||
export async function downloadTripPDF({ trip, days, places, assignments = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit }: downloadTripPDFProps) {
|
||||
/**
|
||||
* The stops the day plan lists, out of everything the store holds for the trip.
|
||||
*
|
||||
* The stop a booked night wrote onto its check-in day is out whatever the switch says:
|
||||
* the day already shows that booking as its accommodation block, and the row would be
|
||||
* the same hotel a second time. The service stops go with the setting. One predicate
|
||||
* for the whole document, so the day lists, the route map, the cover's planned count
|
||||
* and every cost total agree with the plan and with each other, whichever shell asked.
|
||||
*/
|
||||
function planAssignments(assignments: AssignmentsMap, showServiceStops: boolean): AssignmentsMap {
|
||||
return Object.fromEntries(Object.entries(assignments).map(([dayId, list]) => [
|
||||
dayId,
|
||||
(list || []).filter(a => a.accommodation_id == null && (showServiceStops || !isServiceStopType(a.place?.stop_type))),
|
||||
]))
|
||||
}
|
||||
|
||||
// `assignments` is normalised here once, to the plan's own list; every read below
|
||||
// (and fetchPlacePhotos) relies on it being an object.
|
||||
export async function downloadTripPDF({ trip, days, places, assignments: stored = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true }: downloadTripPDFProps) {
|
||||
const assignments = planAssignments(stored, showServiceStops)
|
||||
const breaksPerDay = pageBreakPerDay()
|
||||
const loc = _locale || undefined
|
||||
const tr = _t || (k => k)
|
||||
@@ -335,8 +359,11 @@ 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()
|
||||
.sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
// What the plan lists (planAssignments): the stop a booked night wrote sat at the
|
||||
// head of its day, so the hotel printed above a morning flight (#2434).
|
||||
const assigned = (assignments[String(day.id)] || [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id).slice()
|
||||
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
const cost = dayCost(assignments, day.id, loc, tripCur, fxRates)
|
||||
|
||||
@@ -101,14 +101,15 @@ vi.mock('../shared/Toast', () => ({
|
||||
|
||||
// ── Permissions mock ────────────────────────────────────────────────────────
|
||||
|
||||
// Flipped per test to render the read-only variants of the day rows.
|
||||
const mockPermissions = vi.hoisted(() => ({ canEdit: true }))
|
||||
// Flipped per test to render the read-only variants of the day rows; `denied`
|
||||
// takes single actions away from an otherwise editing member.
|
||||
const mockPermissions = vi.hoisted(() => ({ canEdit: true, denied: new Set<string>() }))
|
||||
|
||||
vi.mock('../../store/permissionsStore', async (importOriginal) => {
|
||||
const actual = await importOriginal() as any
|
||||
return {
|
||||
...actual,
|
||||
useCanDo: () => () => mockPermissions.canEdit,
|
||||
useCanDo: () => (action: string) => mockPermissions.canEdit && !mockPermissions.denied.has(action),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -182,6 +183,7 @@ beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
mockPermissions.canEdit = true
|
||||
mockPermissions.denied.clear()
|
||||
// clearAllMocks keeps implementations, so tests that swap the router out would
|
||||
// otherwise leak into the ones after them.
|
||||
vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({
|
||||
@@ -223,6 +225,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] })} />)
|
||||
@@ -927,6 +940,18 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onEditPlace).toHaveBeenCalledWith(place, assignment.id)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-214: without place_edit the menu still removes from the day but neither edits nor deletes the place (#2446)', () => {
|
||||
mockPermissions.denied.add('place_edit')
|
||||
const place = buildPlace({ id: 42, name: 'Louvre Museum' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />)
|
||||
fireEvent.contextMenu(screen.getByText('Louvre Museum'))
|
||||
expect(screen.getByText(/Remove from day/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Edit')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Arrow reorder buttons ────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-041: arrow down button reorders day assignments', async () => {
|
||||
|
||||
@@ -188,6 +188,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const tripActions = useRef(useTripStore.getState()).current
|
||||
const can = useCanDo()
|
||||
const canEditDays = can('day_edit', trip)
|
||||
// Editing or deleting the place itself is a place right; taking it off the
|
||||
// day stays a day right (#2446).
|
||||
const canEditPlaces = can('place_edit', trip)
|
||||
// The calendar subscription hands out a link that reads the trip without an
|
||||
// account, so it sits behind the same permission as the public share link.
|
||||
const canManageShare = can('share_manage', trip)
|
||||
@@ -1148,6 +1151,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
tripActions,
|
||||
can,
|
||||
canEditDays,
|
||||
canEditPlaces,
|
||||
canManageShare,
|
||||
noteUi,
|
||||
setNoteUi,
|
||||
@@ -1348,6 +1352,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
tripActions,
|
||||
can,
|
||||
canEditDays,
|
||||
canEditPlaces,
|
||||
canManageShare,
|
||||
noteUi,
|
||||
setNoteUi,
|
||||
@@ -1525,7 +1530,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}
|
||||
@@ -2093,13 +2102,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// does not exist here.
|
||||
const navTargets = getNavigationTargets(place)
|
||||
ctxMenu.open(e, [
|
||||
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
canEditPlaces && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
|
||||
...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
|
||||
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
|
||||
{ divider: true },
|
||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
canEditPlaces && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
|
||||
@@ -186,6 +186,128 @@ 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-018d: a key alone is not enough: with Amap or OpenStreetMap picked the list offers nothing', async () => {
|
||||
// The server only honours the request while Google holds the keyed slot;
|
||||
// under another provider the link would re-run the same search and stay.
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true, placesProvider: 'openstreetmap' });
|
||||
server.use(
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }),
|
||||
),
|
||||
);
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const searchInput = screen.getByPlaceholderText('Search places...');
|
||||
await user.type(searchInput, 'Tokyo Station');
|
||||
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
|
||||
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-018e: the link sends the query the list came from, not what the field holds by then', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [], source: 'trek-places' })),
|
||||
http.post('/api/maps/search', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
bodies.push(body);
|
||||
return body.provider === 'google'
|
||||
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
|
||||
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const searchInput = screen.getByPlaceholderText('Search places...');
|
||||
await user.type(searchInput, 'Tokyo Station');
|
||||
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
|
||||
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
|
||||
|
||||
// The list stays while the field is retyped; the line still means this list.
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, 'Kyoto');
|
||||
await user.click(screen.getByRole('button', { name: /Search Google instead/ }));
|
||||
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
|
||||
expect(bodies).toHaveLength(2);
|
||||
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-018f: the link still works after the field was cleared', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/maps/search', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
bodies.push(body);
|
||||
return body.provider === 'google'
|
||||
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
|
||||
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const searchInput = screen.getByPlaceholderText('Search places...');
|
||||
await user.type(searchInput, 'Tokyo Station');
|
||||
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
|
||||
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
|
||||
|
||||
// An empty field used to make the click a silent no-op.
|
||||
await user.clear(searchInput);
|
||||
await user.click(screen.getByRole('button', { name: /Search Google instead/ }));
|
||||
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
|
||||
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
|
||||
@@ -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, selectGoogleHoldsSlot, sourceLabelFor } from '../../utils/placeSource'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -196,7 +196,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const placesSessionRef = useRef(new PlacesSession())
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
|
||||
const { placesEnrichEnabled } = useAuthStore()
|
||||
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
|
||||
const can = useCanDo()
|
||||
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
@@ -396,13 +397,16 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleMapsSearch = async () => {
|
||||
if (!mapsSearch.trim()) return
|
||||
const handleMapsSearch = async (provider?: 'google') => {
|
||||
// The retry sends the query the list came from, not the field: the list
|
||||
// stays on screen while the field is edited or cleared, and the line under
|
||||
// it promises the same query.
|
||||
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : mapsSearch.trim()
|
||||
if (!trimmed) return
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
// A pasted Google Maps or Amap link resolves server-side into a place
|
||||
const trimmed = mapsSearch.trim()
|
||||
if (isMapUrl(trimmed)) {
|
||||
if (!provider && isMapUrl(trimmed)) {
|
||||
const resolved = await mapsApi.resolveUrl(trimmed)
|
||||
if (resolved.lat && resolved.lng) {
|
||||
setForm(prev => ({
|
||||
@@ -419,8 +423,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
|
||||
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
|
||||
const result = await mapsApi.search(trimmed, language, locationBiasPoint, provider)
|
||||
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
|
||||
setMapsResults(result.places || [])
|
||||
setSearchSource(result.source || '')
|
||||
} catch (err: unknown) {
|
||||
@@ -760,7 +764,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
language,
|
||||
locale,
|
||||
timeFormat,
|
||||
hasMapsKey,
|
||||
googleAnswers,
|
||||
placesEnrichEnabled,
|
||||
can,
|
||||
tripObj,
|
||||
@@ -838,7 +842,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
hasMapsKey,
|
||||
googleAnswers,
|
||||
placesEnrichEnabled,
|
||||
can,
|
||||
tripObj,
|
||||
@@ -1002,6 +1006,22 @@ 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
|
||||
where Google holds the key slot and for a list Google did not
|
||||
already produce. */}
|
||||
{mapsResults.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMapsSearch('google')}
|
||||
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 */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { usePluginStore } from '../../store/pluginStore';
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import type { AssignmentsMap } from '../../types';
|
||||
@@ -1478,6 +1479,25 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-102: a member without place_edit gets neither Edit nor Delete nor the inline rename (#2446)', () => {
|
||||
// buildUser() is a plain member and buildTrip({ id: 1 }) belongs to somebody else.
|
||||
usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } });
|
||||
const onEdit = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const onUpdatePlace = vi.fn();
|
||||
const member = render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
|
||||
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||
fireEvent.doubleClick(screen.getByText(place.name));
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
member.unmount();
|
||||
// the same trip owned by this member shows them again
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: useAuthStore.getState().user!.id }) });
|
||||
render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-017b: a stop a booked night wrote is neither removed nor doubled here', () => {
|
||||
// Taking it off the day would leave the booking behind with nothing on the drive and
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { Place, Category, Day, Assignment, Reservation, TripFile, Assignmen
|
||||
import type { CollectionStatus } from '@trek/shared'
|
||||
import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { formatDistance, formatElevation } from '../../utils/units'
|
||||
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
|
||||
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
|
||||
@@ -184,11 +185,21 @@ interface PlaceInspectorProps {
|
||||
export default function PlaceInspector({
|
||||
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
|
||||
assignments = {}, reservations = [], onEditTransport, onEditReservation,
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate,
|
||||
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onRate,
|
||||
leftWidth = 0, rightWidth = 0,
|
||||
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
|
||||
}: PlaceInspectorProps) {
|
||||
// Editing the place is a place right. The planner hands the handlers over
|
||||
// regardless, and a member without the right saw Edit, Delete and the inline
|
||||
// rename and got the server's 403 for each (#2446). Collection mode gates
|
||||
// its own actions.
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const mayEditPlace = mode !== 'trip' || can('place_edit', trip)
|
||||
const onEdit = mayEditPlace ? editPlace : undefined
|
||||
const onDelete = mayEditPlace ? deletePlace : undefined
|
||||
const onUpdatePlace = mayEditPlace ? updatePlace : undefined
|
||||
// Plugins that declared a place-detail slot mount at the bottom of this panel,
|
||||
// scoped to the open place (trip mode only). Inline-filter like the other sites.
|
||||
const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})()}
|
||||
@@ -218,9 +196,9 @@ export function PlacesHeader(S: SidebarState) {
|
||||
{/* Says out loud what the count above already narrowed to.
|
||||
The map has followed the open day on this filter since #2024, and until now
|
||||
nothing anywhere said so: the pool read 55, the map drew five, and the honest
|
||||
conclusion was that the map was broken. It sits under the tabs rather than on
|
||||
the map because a chip over the canvas is unreachable on a phone, which is
|
||||
where this was reported from. */}
|
||||
conclusion was that the map was broken. It sits under the filter select
|
||||
rather than on the map because a chip over the canvas is unreachable on a
|
||||
phone, which is where this was reported from. */}
|
||||
{dayScoped && (
|
||||
<div
|
||||
className="border border-edge-faint bg-surface-tertiary text-content-secondary"
|
||||
|
||||
@@ -96,7 +96,8 @@ describe('TripExportModal', () => {
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
|
||||
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
|
||||
trip, days, showServiceStops: true,
|
||||
dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
|
||||
/**
|
||||
* What a GPX download can carry. Worded by what someone wants on their device
|
||||
@@ -61,6 +62,9 @@ export function TripExportModal({
|
||||
// The PDF is built outside React, so it cannot read this itself (#2066).
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
// The export gets the store's assignments and applies the day plan's own filter to
|
||||
// them, so it needs the same switch the plan reads.
|
||||
const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, tripId)
|
||||
const fileBase = trip?.title || 'trip'
|
||||
|
||||
// Shared tail of every download: Firefox and Safari cancel the download when
|
||||
@@ -87,7 +91,7 @@ export function TripExportModal({
|
||||
// exported. A missing chunk lands in the catch and shows the same error
|
||||
// the export already had.
|
||||
const { downloadTripPDF } = await import('../PDF/TripPDF')
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit })
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit, showServiceStops })
|
||||
onClose()
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -80,6 +80,31 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
|
||||
legIndex: 0,
|
||||
intoLegKm: 182,
|
||||
drivenMeters: 182000,
|
||||
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
|
||||
// traveller's own setting, so a band printing it would read the same figure on
|
||||
// every leg of the day.
|
||||
sinceKm: 600,
|
||||
lat: 52.4,
|
||||
lng: 10.2,
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Idle unless a case says which part of the search it is standing in. */
|
||||
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
|
||||
openFor: null,
|
||||
loading: false,
|
||||
outcome: null,
|
||||
results: [],
|
||||
offered: [],
|
||||
ask: vi.fn(),
|
||||
close: vi.fn(),
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('RoadtripSidebar', () => {
|
||||
/**
|
||||
* The value the summary head pairs with this label. The same words also name the chips
|
||||
@@ -585,11 +610,16 @@ describe('RoadtripSidebar', () => {
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car', () => {
|
||||
const ferry = leg({ mode: 'ferry' })
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [ferry] })] })} />)
|
||||
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car, a ride keeps its own', () => {
|
||||
const unknown = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [leg({ mode: 'hovercraft' })] })] })} />)
|
||||
expect(unknown.container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
|
||||
unknown.unmount()
|
||||
|
||||
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
|
||||
// A ferry leg is a booked ride since the carrier seams (#2429), not a drive
|
||||
// whose mode nobody drew an icon for.
|
||||
const ride = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [leg({ mode: 'ferry' })] })] })} />)
|
||||
expect(ride.container.querySelector('svg.lucide-sailboat')).toBeInTheDocument()
|
||||
expect(ride.container.querySelector('svg.lucide-car-front')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
|
||||
@@ -706,31 +736,6 @@ describe('RoadtripSidebar', () => {
|
||||
* point the tank ran dry. A station offered at the warning is one the car cannot reach.
|
||||
*/
|
||||
describe('the leg the tank runs out on', () => {
|
||||
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
|
||||
legIndex: 0,
|
||||
intoLegKm: 182,
|
||||
drivenMeters: 182000,
|
||||
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
|
||||
// traveller's own setting, so a band printing it would read the same figure on
|
||||
// every leg of the day.
|
||||
sinceKm: 600,
|
||||
lat: 52.4,
|
||||
lng: 10.2,
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Idle unless a case says which part of the search it is standing in. */
|
||||
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
|
||||
openFor: null,
|
||||
loading: false,
|
||||
outcome: null,
|
||||
results: [],
|
||||
offered: [],
|
||||
ask: vi.fn(),
|
||||
close: vi.fn(),
|
||||
...over,
|
||||
})
|
||||
|
||||
const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
|
||||
osm_id: `osm-${name}`,
|
||||
name,
|
||||
@@ -1087,3 +1092,241 @@ 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)
|
||||
})
|
||||
|
||||
/** The card a ride lands on, joined to the card before it by the ride itself. */
|
||||
const landingDay = (arrivingLeg: RouteSegment) => {
|
||||
const arrival = terminal('arrival')
|
||||
return day({
|
||||
dayId: 2,
|
||||
dayNumber: 2,
|
||||
stops: [{ ...arrival, time: '07:00', carrier: { ...arrival.carrier!, at: '07:00' } }, stop({ assignmentId: 2, name: 'Munich' })],
|
||||
legs: [leg()],
|
||||
arrivingLeg,
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '07:00', departure: '07:00', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '08:00', departure: '08:00', anchored: false, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-054: the join into the day a ride lands on is the ride, not a car driving no distance', () => {
|
||||
const flown = leg({ mode: 'flight', distance: 0, duration: 9 * 3600, distanceText: '', durationText: '9 h' })
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [landingDay(flown)], totalStops: 1 })} />)
|
||||
expect(screen.getByText('LH 2020 · 9 h')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/0 m in 9 h/)).not.toBeInTheDocument()
|
||||
// Under the booking's own icon, not the car the road out of the airport wears.
|
||||
const band = screen.getByText('LH 2020 · 9 h').parentElement!
|
||||
expect(band.querySelector('svg.lucide-plane')).toBeInTheDocument()
|
||||
expect(band.querySelector('svg.lucide-car-front')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-055: a ride without a timetable still names the booking rather than passing for a hop', () => {
|
||||
const untimed = leg({ mode: 'flight', distance: 0, duration: 0, distanceText: '', durationText: '' })
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [landingDay(untimed)], totalStops: 1 })} />)
|
||||
expect(screen.getByText('LH 2020')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-056: the tank starts afresh after a flight, and the road out of the arrival can be the one it runs out on', () => {
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [{ ...flightDay(), dryPoints: [dry({ legIndex: 2 })] }], totalStops: 2 })} refuel={search()} />,
|
||||
)
|
||||
// Hamburg, the ride block, Munich: the band hangs under the block, where the road
|
||||
// out of the arrival leaves, and nowhere else.
|
||||
const rows = container.querySelectorAll('li')
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
|
||||
expect(within(rows[1] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () => {
|
||||
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
|
||||
|
||||
const rentalDay = () => {
|
||||
const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
|
||||
return 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: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => {
|
||||
const onOpenBooking = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [rentalDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
|
||||
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-057: a tank that empties on the road out of the pick-up desk is offered a fill-up under the desk', () => {
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [{ ...rentalDay(), dryPoints: [dry({ legIndex: 0 })] }], totalStops: 2 })} refuel={search()} />,
|
||||
)
|
||||
const rows = container.querySelectorAll('li')
|
||||
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
|
||||
expect(within(rows[0] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-053: a table pinned to a stop, or booked at its place on its day, hangs under the stop as a chip that opens it; one for no stop is listed under the day', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
|
||||
const reservations = [
|
||||
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} canEditBookings />)
|
||||
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()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-058: for a reader who may not edit bookings, a table is a plain chip while a taxi still opens its detail view', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
|
||||
const reservations = [
|
||||
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
|
||||
booking({ id: 12, title: 'Taxi zum Hafen', type: 'taxi', reservation_time: '2026-10-05T18:30', assignment_id: 1 }),
|
||||
]
|
||||
const onOpenBooking = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
|
||||
// The table is there to read, not a button that does nothing; the taxi is a button.
|
||||
expect(screen.getByText('Tisch Bullerei').closest('button')).toBeNull()
|
||||
expect(screen.getByText('Taxi zum Hafen').closest('button')).not.toBeNull()
|
||||
fireEvent.click(screen.getByText('Tisch Bullerei'))
|
||||
expect(onOpenBooking).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('Taxi zum Hafen'))
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(12)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,12 +17,13 @@ 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'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared'
|
||||
import { isCarrierMode, type CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { SpillMark } from './nightSpill'
|
||||
import { dayColor } from './dayColors'
|
||||
@@ -31,6 +32,9 @@ import { FS } from './typeScale'
|
||||
import type { RouteSegment } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import AutomaticDayStop from './AutomaticDayStop'
|
||||
import { bookingOpens, carrierIcon, rideText, terminalLine } from './carrierRide'
|
||||
import { 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 +106,22 @@ 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
|
||||
/**
|
||||
* Whether the reader may open a table's or a ticket's editor. A transport has a detail
|
||||
* view anybody may look at; the other chips have only their editor, and without the
|
||||
* right to it they stay plain rather than being a button that does nothing (#2012).
|
||||
*/
|
||||
canEditBookings?: boolean
|
||||
}
|
||||
|
||||
const MODE_ICON: Record<string, LucideIcon> = {
|
||||
@@ -670,8 +690,10 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
|
||||
)
|
||||
}
|
||||
|
||||
function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
|
||||
function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: {
|
||||
leg: RouteSegment | undefined
|
||||
/** The booking, when the leg is a ride and not a road: the join into the day it lands on. */
|
||||
carrier?: CarrierTerminal
|
||||
/** Asks for other ways of driving this leg. Absent means the route is not editable. */
|
||||
onAskAlternatives?: () => void
|
||||
alternativesOpen?: boolean
|
||||
@@ -679,19 +701,37 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const mode = leg?.mode ?? 'driving'
|
||||
const Icon = mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront
|
||||
// A ride reads as the ride block's pill does: the booking and its minutes under the
|
||||
// booking's icon. Through the road branches it was a car driving no distance for nine
|
||||
// hours, and a ride without a timetable was short enough to pass for a hop.
|
||||
const ride = isCarrierMode(mode)
|
||||
const Icon = ride ? carrierIcon(mode) : mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront
|
||||
// A hop (the hire desk beside the terminal) keeps the line and drops the pill: there is
|
||||
// nothing to say about it and no other way to drive it.
|
||||
if (!ride && isHop(leg)) {
|
||||
return (
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<span className="relative z-[1] flex min-h-[10px] flex-col items-center" aria-hidden>
|
||||
<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 = (
|
||||
<>
|
||||
<Icon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
|
||||
{leg
|
||||
? t('roadtrip.leg.driveText', {
|
||||
distance: formatDistance(leg.distance / 1000, distanceUnit),
|
||||
time: formatDurationShort(leg.duration),
|
||||
})
|
||||
: t('roadtrip.leg.pending')}
|
||||
{!leg
|
||||
? t('roadtrip.leg.pending')
|
||||
: ride
|
||||
? rideText(carrier, leg)
|
||||
: t('roadtrip.leg.driveText', {
|
||||
distance: formatDistance(leg.distance / 1000, distanceUnit),
|
||||
time: formatDurationShort(leg.duration),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
@@ -748,6 +788,185 @@ 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, continues, starts, onOpen }: {
|
||||
stop: RoadtripStop
|
||||
entry: ScheduleEntry | undefined
|
||||
late: ScheduleWarning[]
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const Icon = carrierIcon(stop.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="min-w-0 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
|
||||
<RideEnd stop={stop} entry={entry} late={late} />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One end of a ride: the terminal's name and code, the timetable's clock under it, and
|
||||
* on the right the time the chain has the traveller there. Inside the ride's block for
|
||||
* a same-day ride, and the whole of a lone terminal's row.
|
||||
*/
|
||||
function RideEnd({ stop, entry, late }: {
|
||||
stop: RoadtripStop
|
||||
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 gap-0.5">
|
||||
<span className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
<span className="min-w-0 break-words">{stop.name}</span>
|
||||
{stop.carrier!.code ? (
|
||||
<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, canEdit, onOpen }: {
|
||||
bookings: Reservation[]
|
||||
continues: boolean
|
||||
/** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
|
||||
canEdit: boolean
|
||||
onOpen?: (reservationId: number) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
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 && bookingOpens(r, canEdit) ? (
|
||||
<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 +1508,7 @@ function SpillBlock({ spill, children }: {
|
||||
* move, a stay edit or a refuel offer still names the day the server knows it by. See
|
||||
* `nightSpill.ts`.
|
||||
*/
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, dayOrder, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
|
||||
onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
|
||||
day: RoadtripDay
|
||||
/** Folded down to the header, and off the map with it. */
|
||||
@@ -1303,6 +1522,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
drag: DragState
|
||||
onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives']
|
||||
openAlternatives?: RoadtripSidebarProps['openAlternatives']
|
||||
onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
|
||||
canEditBookings?: boolean
|
||||
reservations?: Reservation[]
|
||||
/** A day's place in the trip by its id, so a booking spanning days is seen on the days between. */
|
||||
dayOrder?: (dayId: number) => number | null
|
||||
onEditStay?: RoadtripSidebarProps['onEditStay']
|
||||
onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
|
||||
onSetStopFill?: RoadtripSidebarProps['onSetStopFill']
|
||||
@@ -1319,6 +1543,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 ?? [], dayOrder), [day, reservations, dayOrder])
|
||||
const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
|
||||
const { t, language } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
@@ -1341,27 +1566,102 @@ 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'))
|
||||
/**
|
||||
* The band for the tank running out on the leg leaving `legIndex`, whichever row owns
|
||||
* that leg: a stop, a night, a terminal. Only once the day has finished routing: the
|
||||
* warnings are republished after every routing task and the early ones are wrong, so
|
||||
* an offer that appears and moves while the trip loads reads as a fault.
|
||||
*
|
||||
* Keyed by the card's day, not the stop's stored one. This is the key the open search
|
||||
* is filed under, and the hook is asked with the same number. A band inside a borrowed
|
||||
* stretch had them differ, so the results arrived, drew on the map, and the band they
|
||||
* belonged to never opened.
|
||||
*/
|
||||
const refuelBandsFor = (legIndex: number): React.ReactElement[] | null =>
|
||||
refuel && !loading
|
||||
? (day.dryPoints ?? []).filter(dry => dry.legIndex === legIndex).map(dry => (
|
||||
<RefuelBand
|
||||
key={`dry-${dry.legIndex}`}
|
||||
dry={dry}
|
||||
dayId={day.dayId}
|
||||
refuel={refuel}
|
||||
onAsk={() => onAskRefuel?.(day.dayId, dry)}
|
||||
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined}
|
||||
/>
|
||||
))
|
||||
: null
|
||||
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement | null => {
|
||||
if (stop.automaticNight) return (
|
||||
<li key={stop.assignmentId}>
|
||||
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
|
||||
{i < last && day.legs[i]?.distance !== 0 ? <DriveBand leg={day.legs[i]} /> : null}
|
||||
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === i).map(dry => (
|
||||
<RefuelBand key={`dry-${dry.legIndex}`} dry={dry} dayId={day.dayId} refuel={refuel}
|
||||
onAsk={() => onAskRefuel?.(day.dayId, dry)}
|
||||
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
|
||||
)) : null}
|
||||
{refuelBandsFor(i)}
|
||||
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
|
||||
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
|
||||
)) : null}
|
||||
</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}
|
||||
continues={i < last}
|
||||
starts={i === 0}
|
||||
onOpen={open}
|
||||
/>
|
||||
)}
|
||||
{tail < last && (!after?.automaticNight || day.legs[tail]?.distance !== 0) ? (
|
||||
/* The row the road leaves from carries the booking whenever that road is a
|
||||
ride itself (a departure terminal seated mid-day), and the band needs it
|
||||
to say more than a bare icon. */
|
||||
<DriveBand leg={day.legs[tail]} carrier={day.stops[tail]?.carrier} />
|
||||
) : null}
|
||||
{/* The tank starts afresh after a flight and full at a pick-up desk, and the
|
||||
first road out of either can be the one that empties it. */}
|
||||
{refuelBandsFor(tail)}
|
||||
{tail < last ? (day.legVias[tail] ?? []).map((via, vi) => (
|
||||
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
|
||||
)) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
const service = isServiceStopType(stop.stopType)
|
||||
if (!service) counted += 1
|
||||
const ownDay = stop.ownerDayId
|
||||
const ownIndex = stop.ownerIndex
|
||||
return (
|
||||
@@ -1426,6 +1726,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} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
|
||||
) : null}
|
||||
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
|
||||
<DriveBand
|
||||
leg={day.legs[i]}
|
||||
@@ -1433,28 +1736,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
|
||||
/>
|
||||
) : null}
|
||||
{/* Only on the leg the fuel actually runs out on, and only once the day has
|
||||
finished routing: the warnings are republished after every routing task
|
||||
and the early ones are wrong, so an offer that appears and moves while
|
||||
the trip loads reads as a fault. */}
|
||||
{refuel && !loading
|
||||
? (day.dryPoints ?? [])
|
||||
.filter(dry => dry.legIndex === i)
|
||||
.map(dry => (
|
||||
<RefuelBand
|
||||
key={`dry-${dry.legIndex}`}
|
||||
dry={dry}
|
||||
// The card's day, not the stop's stored one. This is the key the open
|
||||
// search is filed under, and the hook is asked with the same number —
|
||||
// a band inside a borrowed stretch had them differ, so the results
|
||||
// arrived, drew on the map, and the band they belonged to never opened.
|
||||
dayId={day.dayId}
|
||||
refuel={refuel}
|
||||
onAsk={() => onAskRefuel?.(day.dayId, dry)}
|
||||
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{refuelBandsFor(i)}
|
||||
{/* After the band, because a plugin halt happens on the drive it describes
|
||||
rather than before setting off. */}
|
||||
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
|
||||
@@ -1471,18 +1753,15 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
* Built rather than mapped straight through, because a spill is drawn inside a block of
|
||||
* its own and a block cannot be opened halfway down a `map`.
|
||||
*/
|
||||
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => (
|
||||
<React.Fragment key={stop.assignmentId}>
|
||||
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === -i - 1).map(dry => (
|
||||
<li key={`inbound-${i}`}>
|
||||
<RefuelBand dry={dry} dayId={day.dayId} refuel={refuel}
|
||||
onAsk={() => onAskRefuel?.(day.dayId, dry)}
|
||||
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
|
||||
</li>
|
||||
)) : null}
|
||||
{renderStopContent(stop, i)}
|
||||
</React.Fragment>
|
||||
)
|
||||
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => {
|
||||
const inbound = refuelBandsFor(-i - 1)
|
||||
return (
|
||||
<React.Fragment key={stop.assignmentId}>
|
||||
{inbound?.length ? <li key={`inbound-${i}`}>{inbound}</li> : null}
|
||||
{renderStopContent(stop, i)}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
const runs: { spill: SpillMark | null; from: number; to: number }[] = []
|
||||
{
|
||||
let at = 0
|
||||
@@ -1553,12 +1832,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 +1852,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
|
||||
@@ -1619,7 +1904,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
happens. Without it a morning that starts at 08:11 after a stay that ended at
|
||||
08:00 looks like eleven minutes went missing. A day whose first stop crossed
|
||||
over instead gets that road under its own block, so this is left out there. */}
|
||||
{day.arrivingLeg ? <DriveBand leg={day.arrivingLeg} /> : null}
|
||||
{/* A day that opens on an arrival terminal is joined by the ride, and the
|
||||
terminal carries the booking the band names. */}
|
||||
{day.arrivingLeg ? <DriveBand leg={day.arrivingLeg} carrier={day.stops[0]?.carrier} /> : null}
|
||||
{runs.map(run => (
|
||||
run.spill ? (
|
||||
<SpillBlock key={`spill-${run.from}`} spill={run.spill}>
|
||||
@@ -1631,6 +1918,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} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
|
||||
</li>
|
||||
) : null}
|
||||
</ol>
|
||||
{picking ? (
|
||||
<StopKindPicker
|
||||
@@ -1731,7 +2030,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, canEditBookings, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
|
||||
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
|
||||
collapsedDayIds, onToggleDay, onFocusPoint,
|
||||
}: RoadtripSidebarProps): React.ReactElement {
|
||||
@@ -1741,6 +2040,13 @@ export default function RoadtripSidebar({
|
||||
const [from, setFrom] = React.useState<DragState['from']>(null)
|
||||
const [dropAt, setDropAt] = React.useState<DragState['dropAt']>(null)
|
||||
const drag: DragState = { from, setFrom, dropAt, setDropAt }
|
||||
// Where each day stands in the trip, so a booking that spans several days is listed on
|
||||
// the days between its two ends as well, the way the day plan lists a three-day tour.
|
||||
// Quiet days count: a span can run straight over one.
|
||||
const dayOrder = useMemo(() => {
|
||||
const numbers = new Map([...routes.days, ...routes.quietDays].map(d => [d.dayId, d.dayNumber]))
|
||||
return (dayId: number): number | null => numbers.get(dayId) ?? null
|
||||
}, [routes.days, routes.quietDays])
|
||||
|
||||
// Nothing to total up, so nothing pretends to: no "0 km" standing above "No route yet".
|
||||
if (routes.days.length === 0) {
|
||||
@@ -1789,6 +2095,10 @@ export default function RoadtripSidebar({
|
||||
drag={drag}
|
||||
onAskAlternatives={onAskAlternatives}
|
||||
openAlternatives={openAlternatives}
|
||||
onOpenBooking={onOpenBooking}
|
||||
canEditBookings={canEditBookings}
|
||||
reservations={reservations}
|
||||
dayOrder={dayOrder}
|
||||
onEditStay={onEditStay}
|
||||
onSetStopKind={onSetStopKind}
|
||||
onSetStopFill={onSetStopFill}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
|
||||
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { Reservation, TranslationFn } from '../../types'
|
||||
|
||||
/**
|
||||
* How a ride and its two terminals, and a hire car's two desks, read on the rail and in
|
||||
* the phone chain (#2428), and which of the bookings under a stop open there.
|
||||
*
|
||||
* 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.
|
||||
* A ride leg met without its booking is just the minutes.
|
||||
*/
|
||||
export function rideText(carrier: Pick<CarrierTerminal, 'title'> | undefined, seg: RouteSegment | undefined): string {
|
||||
const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
|
||||
const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '')
|
||||
if (!carrier) return duration
|
||||
return duration ? `${carrier.title} · ${duration}` : carrier.title
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a booking chip opens for this reader. A transport has a detail view anybody
|
||||
* may look at; a table or a ticket has only its editor, the split the planner page makes
|
||||
* when it opens one. Without the right to edit bookings the chip stays a plain chip,
|
||||
* the way the place inspector's booking strip stays inert (#2012).
|
||||
*/
|
||||
export function bookingOpens(booking: Pick<Reservation, 'type'>, canEditBookings: boolean): boolean {
|
||||
return TRANSPORT_TYPES.has(booking.type) || canEditBookings
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround } from '@trek/shared/roadtrip'
|
||||
export type { LatLng, Bbox, CorridorHit } from '@trek/shared/roadtrip'
|
||||
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround, rideGaps, riddenRanges, inRiddenRange, drivenPieces } from '@trek/shared/roadtrip'
|
||||
export type { LatLng, Bbox, CorridorHit, KmRange, RideGap } from '@trek/shared/roadtrip'
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
|
||||
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
|
||||
// FE-RTROW-001 to FE-RTROW-044
|
||||
// FE-RTROW-001 to FE-RTROW-055
|
||||
|
||||
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
|
||||
return {
|
||||
@@ -520,3 +520,132 @@ 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')
|
||||
})
|
||||
|
||||
/** Bremen at 09:00, the airport at 12:20 for a 13:20 flight, landing at 14:30, nothing after. */
|
||||
const endsOnRide = () =>
|
||||
day([stop('Bremen'), terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
|
||||
legs: [seg(0), ride],
|
||||
schedule: { entries: [entry('09:00'), entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
|
||||
})
|
||||
/** The flight first, the hotel after it. */
|
||||
const opensOnRide = () =>
|
||||
day([terminal('Munich Airport', 'departure', '06:00'), terminal('Hamburg Airport', 'arrival', '07:10'), stop('Hotel')], {
|
||||
legs: [ride, seg(1)],
|
||||
schedule: { entries: [entry('05:00', { departure: '06:00' }), entry('07:10'), entry('09:00')], warnings: [] },
|
||||
})
|
||||
|
||||
it('FE-RTROW-052: a day that ends on a ride arrives at the arrival terminal, one that opens on a ride starts at the departure', () => {
|
||||
// The head card printed the clock of the stop before the flight as where the day ends,
|
||||
// while the ride block right below it printed the landing.
|
||||
expect(stageClocks(roadtripRows(endsOnRide()))).toEqual({ start: '09:00', arrive: '14:30' })
|
||||
expect(stageClocks(roadtripRows(opensOnRide()))).toEqual({ start: '05:00', arrive: '09:00' })
|
||||
})
|
||||
|
||||
it('FE-RTROW-053: a day that is nothing but a ride still has both clocks', () => {
|
||||
const rideOnly = day([terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
|
||||
legs: [ride],
|
||||
schedule: { entries: [entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
|
||||
})
|
||||
expect(stageClocks(roadtripRows(rideOnly))).toEqual({ start: '12:20', arrive: '14:30' })
|
||||
})
|
||||
|
||||
it('FE-RTROW-054: up next names the departure terminal before its check-in, and the arrival after take-off', () => {
|
||||
// A lone terminal (a ride landing tomorrow) was already a candidate; the terminals
|
||||
// inside a same-day ride were not, so the countdown skipped the airport.
|
||||
expect(upNextStop(endsOnRide(), 10 * 60, true)?.row.stop.name).toBe('Munich Airport')
|
||||
expect(upNextStop(endsOnRide(), 10 * 60, true)?.minutesUntil).toBe(140)
|
||||
expect(upNextStop(endsOnRide(), 13 * 60 + 30, true)?.row.stop.name).toBe('Hamburg Airport')
|
||||
expect(upNextStop(endsOnRide(), 15 * 60 + 1, true)?.minutesUntil).toBe(-31)
|
||||
})
|
||||
|
||||
it('FE-RTROW-055: a stage that ends on a ride ends at its arrival terminal', () => {
|
||||
const end = stageEnd(roadtripRows(endsOnRide()))
|
||||
expect(end?.stop.name).toBe('Hamburg Airport')
|
||||
expect(end?.time).toBe('14:30')
|
||||
expect(stageEnd(roadtripRows(opensOnRide()))?.stop.name).toBe('Hotel')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,28 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows with each ride opened up into its two terminals, in place. A ride row holds
|
||||
* them instead of two stop rows of their own, so a reader walking the rows for stops
|
||||
* has to look inside it, or a day that ends on a flight ends at the stop before it.
|
||||
*/
|
||||
function unfoldRides(rows: readonly RoadtripRow[]): RoadtripRow[] {
|
||||
return rows.flatMap(row => (row.kind === 'ride' ? [row.departure, row.arrival] : [row]))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,7 +293,7 @@ export function destinationCount(day: RoadtripDay): number {
|
||||
export function stageClocks(rows: readonly RoadtripRow[]): { start: string | null; arrive: string | null } {
|
||||
let start: string | null = null
|
||||
let arrive: string | null = null
|
||||
for (const row of rows) {
|
||||
for (const row of unfoldRides(rows)) {
|
||||
if (row.kind === 'stop' && row.time) {
|
||||
start ??= row.time
|
||||
arrive = row.time
|
||||
@@ -241,7 +305,8 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
|
||||
}
|
||||
|
||||
/**
|
||||
* The stop a stage ends on: the last one the chain draws, a service stop included.
|
||||
* The stop a stage ends on: the last one the chain draws, a service stop or the arrival
|
||||
* terminal of a ride included.
|
||||
*
|
||||
* The map half names it in its bar and opens it on a tap, so the name, the clock beside it
|
||||
* and the sheet the tap brings up all come off this one row and cannot name three different
|
||||
@@ -253,8 +318,9 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
|
||||
* There is nothing for a tap to open then, and the bar says so by not being a button.
|
||||
*/
|
||||
export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const row = rows[i]
|
||||
const unfolded = unfoldRides(rows)
|
||||
for (let i = unfolded.length - 1; i >= 0; i--) {
|
||||
const row = unfolded[i]
|
||||
if (row.kind === 'stop') return row
|
||||
}
|
||||
return null
|
||||
@@ -276,7 +342,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
|
||||
@@ -300,7 +366,7 @@ export function upNextStop(
|
||||
isToday: boolean,
|
||||
): { row: StopRow; minutesUntil: number } | null {
|
||||
if (!day || !isToday) return null
|
||||
const timed = roadtripRows(day)
|
||||
const timed = unfoldRides(roadtripRows(day))
|
||||
.filter((r): r is StopRow => r.kind === 'stop' && !r.service)
|
||||
.map(row => ({ row, at: clockMinutes(row.time) }))
|
||||
.filter((x): x is { row: StopRow; at: number } => x.at != null)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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-005: a booking spanning several days is on the days between as well, unless its type hides there', () => {
|
||||
// Day ids out of order on purpose: the span is read by the days' position, not by id.
|
||||
const order = (dayId: number) => ({ 5: 0, 1: 1, 3: 2 } as Record<number, number>)[dayId] ?? null
|
||||
const tour = booking({ id: 1, title: 'Three-day tour', type: 'tour', day_id: 5, end_day_id: 3 })
|
||||
const parked = booking({ id: 2, title: 'Parked', type: 'parking', day_id: 5, end_day_id: 3 })
|
||||
const dinner = booking({ id: 3, title: 'Dinner', day_id: 5, end_day_id: 3, place_id: 110 })
|
||||
const { atStop, loose } = dayBookings(day, [tour, parked, dinner], order)
|
||||
expect(loose.map(r => r.title)).toEqual(['Three-day tour'])
|
||||
// On the day between, a booking for a place the day drives to still hangs under it.
|
||||
expect(atStop.get(1)!.map(r => r.title)).toEqual(['Dinner'])
|
||||
// Its first and last day list the parked car as before.
|
||||
expect(dayBookings({ ...day, dayId: 3 }, [parked], order).loose).toHaveLength(1)
|
||||
// Without a day order, or for a day the order does not know, only the two ends count.
|
||||
expect(dayBookings(day, [tour])).toEqual({ atStop: new Map(), loose: [] })
|
||||
expect(dayBookings({ ...day, dayId: 9 }, [tour], order)).toEqual({ atStop: new Map(), loose: [] })
|
||||
})
|
||||
|
||||
it('FE-STOPBOOKINGS-004: the chip wears the booking panel\'s icon and the clock the booking starts at', () => {
|
||||
expect(bookingIcon('restaurant')).toBe(Utensils)
|
||||
expect(bookingIcon('car')).toBe(Car)
|
||||
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,101 @@
|
||||
import { Ticket, type LucideIcon } from 'lucide-react'
|
||||
import { isCarrierType, isRentalType } from '@trek/shared/roadtrip'
|
||||
import { RES_ICONS } from '../Planner/DayPlanSidebar.constants'
|
||||
import { hidesOnMiddleDay } from '../../utils/dayMerge'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
import type { Reservation } from '../../types'
|
||||
import type { RoadtripStop } from '@trek/shared/roadtrip'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a booking is on the day: it starts or ends there, or the day lies between the
|
||||
* two, the way the day plan lists a three-day tour on its middle day as well. A type
|
||||
* that has nothing to say on its middle days (a parked car) is left off them here too.
|
||||
* Without a day order only the first and the last day of a span can be told.
|
||||
*/
|
||||
function spansDay(r: Reservation, dayId: number, dayOrder?: (dayId: number) => number | null | undefined): boolean {
|
||||
if (r.day_id === dayId || (r.end_day_id != null && r.end_day_id === dayId)) return true
|
||||
if (!dayOrder || r.day_id == null || r.end_day_id == null) return false
|
||||
const here = dayOrder(dayId)
|
||||
const first = dayOrder(r.day_id)
|
||||
const last = dayOrder(r.end_day_id)
|
||||
if (here == null || first == null || last == null) return false
|
||||
return here > Math.min(first, last) && here < Math.max(first, last) && !hidesOnMiddleDay(r, dayId)
|
||||
}
|
||||
|
||||
export function dayBookings(
|
||||
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight'>[] },
|
||||
reservations: readonly Reservation[],
|
||||
/** A day's position in the trip by its id, so a booking spanning days is on the days between too. */
|
||||
dayOrder?: (dayId: number) => number | null | undefined,
|
||||
): DayBookings {
|
||||
if (!reservations.length) return EMPTY
|
||||
const atStop = new Map<number, Reservation[]>()
|
||||
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 = spansDay(r, day.dayId, dayOrder)
|
||||
if (index < 0 && onDay && r.place_id) index = stops.findIndex((s, i) => real(i) && s.placeId === r.place_id)
|
||||
if (index >= 0) {
|
||||
const list = atStop.get(index) ?? []
|
||||
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,42 @@ 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)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-019: the gaps are a dependency like any other, so a memoised array keeps `search` and a fresh one remakes it', () => {
|
||||
// The caller memoises the array per day; the hook does not key it on its contents
|
||||
// behind the lint rule's back.
|
||||
const gaps = [{ from: LONG[3], to: LONG[8] }]
|
||||
const categories = ['fuel']
|
||||
const { result, rerender } = renderHook(
|
||||
({ options }: { options: { gaps: typeof gaps } }) => useCorridorPois(LONG, categories, 10, options),
|
||||
{ initialProps: { options: { gaps } } },
|
||||
)
|
||||
const search = result.current.search
|
||||
rerender({ options: { gaps } })
|
||||
expect(result.current.search).toBe(search)
|
||||
rerender({ options: { gaps: [...gaps] } })
|
||||
expect(result.current.search).not.toBe(search)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { corridorTiles, projectOntoRoute, simplifyLine, sliceAtMeters, type Bbox, type LatLng } from './corridor'
|
||||
import { corridorTiles, drivenPieces, inRiddenRange, projectOntoRoute, riddenRanges, simplifyLine, type Bbox, type LatLng } from './corridor'
|
||||
import { DESKTOP_CORRIDOR_BUDGET, type CorridorBudget } from './corridorSearchModel'
|
||||
import type { Poi } from '../Map/poiCategories'
|
||||
|
||||
@@ -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. Memoised by the caller: the array's identity is what
|
||||
* recreates `search`, so a fresh one every render would recreate it every render.
|
||||
*/
|
||||
gaps?: { from: LatLng; to: LatLng }[]
|
||||
},
|
||||
): CorridorSearch {
|
||||
const budget = options?.budget ?? DESKTOP_CORRIDOR_BUDGET
|
||||
const gaps = options?.gaps
|
||||
const { locale } = useTranslation()
|
||||
const [results, setResults] = useState<CorridorPoi[]>([])
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0 })
|
||||
@@ -137,9 +150,9 @@ export function useCorridorPois(
|
||||
// Only the TILE line is cut, never the spine: every `alongKm` in a result is a
|
||||
// distance along the spine, and so is every entry in `stopsAlongKm`. Cutting the
|
||||
// spine would renumber both the moment the window moved, and a hit would land at the
|
||||
// wrong position in the day's chain.
|
||||
const tileLine = window ? sliceAtMeters(spine, window.fromKm * 1000, window.toKm * 1000) : spine
|
||||
const allTiles = corridorTiles(tileLine, widthKm)
|
||||
// wrong position in the day's chain. The MCP corridor tool cuts the same way.
|
||||
const ridden = riddenRanges(spine, gaps ?? [])
|
||||
const allTiles = drivenPieces(spine, ridden, window).flatMap(piece => corridorTiles(piece, widthKm))
|
||||
const tiles = allTiles.slice(0, budget.maxTiles)
|
||||
const startedAt = Date.now()
|
||||
const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs
|
||||
@@ -184,7 +197,7 @@ export function useCorridorPois(
|
||||
for (const poi of data.pois) {
|
||||
if (seen.has(poi.osm_id)) continue
|
||||
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
|
||||
if (!hit || hit.offRouteKm > widthKm) continue
|
||||
if (!hit || hit.offRouteKm > widthKm || inRiddenRange(ridden, hit.alongKm)) continue
|
||||
seen.set(poi.osm_id, {
|
||||
...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null,
|
||||
opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null,
|
||||
@@ -251,7 +264,7 @@ export function useCorridorPois(
|
||||
setError(failures === jobs.length && failures > 0)
|
||||
setLoading(false)
|
||||
})()
|
||||
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs])
|
||||
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gaps])
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
|
||||
@@ -313,4 +313,37 @@ describe('useRoadtripCorridor', () => {
|
||||
expect(result.current.insertIndexFor(beforeFirstStop)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-038: the leg between a departure terminal and its arrival is no section', () => {
|
||||
// The search leaves the ride out and drops every hit inside it, so a section around
|
||||
// its midpoint could only ever come back empty. The two terminals stay offered.
|
||||
const terminal = (role: 'departure' | 'arrival', index: number, lat: number, lng: number) => ({
|
||||
assignmentId: -3000000014 - index, ownerDayId: 1, ownerIndex: index, placeId: -7, name: role, lat, lng,
|
||||
time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
|
||||
carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: null, at: null },
|
||||
})
|
||||
const spine = [
|
||||
{ lat: 53.5, lng: 9.9 }, { lat: 53.0, lng: 11.0 }, { lat: 52.5, lng: 13.4 }, { lat: 52.0, lng: 14.5 },
|
||||
]
|
||||
useCorridorPois.mockReturnValue({ ...searchWith([]), spine })
|
||||
const [first, last] = day().stops
|
||||
const flightDay = day({
|
||||
stops: [
|
||||
first,
|
||||
terminal('departure', 1, 53.0, 11.0),
|
||||
terminal('arrival', 2, 52.5, 13.4),
|
||||
{ ...last, assignmentId: 3, ownerIndex: 3, lat: 52.0, lng: 14.5 },
|
||||
],
|
||||
geometry: spine.map(p => [p.lat, p.lng] as [number, number]),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([flightDay])))
|
||||
|
||||
// On the old code the list also carries 'leg:1', the midpoint of the flight.
|
||||
expect(result.current.anchors.map(a => `${a.kind}:${a.index}`))
|
||||
.toEqual(['stop:0', 'leg:0', 'stop:1', 'stop:2', 'leg:2', 'stop:3'])
|
||||
// The same ride is what the search is told to leave out.
|
||||
const asked = useCorridorPois.mock.calls[useCorridorPois.mock.calls.length - 1]
|
||||
expect(asked[3].gaps).toEqual([{ index: 1, from: { lat: 53.0, lng: 11.0 }, to: { lat: 52.5, lng: 13.4 } }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCor
|
||||
import type { CorridorBudget } from './corridorSearchModel'
|
||||
import { sectionAnchors, insertIndexForAlong } from './roadtripModel'
|
||||
import type { SectionAnchor } from './roadtripModel'
|
||||
import { projectOntoRoute, type LatLng } from './corridor'
|
||||
import { projectOntoRoute, rideGaps, type LatLng } from './corridor'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
@@ -119,7 +119,11 @@ 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 rides = useMemo(() => (day ? rideGaps(day.stops) : []), [day])
|
||||
|
||||
const search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps: rides }), [options, rides]))
|
||||
|
||||
/**
|
||||
* Whether what the panel looks for has been decided yet.
|
||||
@@ -193,8 +197,14 @@ export function useRoadtripCorridor(
|
||||
* Resolved here rather than stored: `section` names an anchor, and its kilometre figure
|
||||
* is looked up fresh, so changing the corridor width does not move the point the list
|
||||
* is narrowed around.
|
||||
*
|
||||
* No break is planned in the middle of a ride: the search never covers it, so the leg
|
||||
* between a departure terminal and its arrival is not offered as a section.
|
||||
*/
|
||||
const anchors = useMemo(() => sectionAnchors(stopsAlongKm), [stopsAlongKm])
|
||||
const anchors = useMemo(
|
||||
() => sectionAnchors(stopsAlongKm).filter(a => a.kind !== 'leg' || !rides.some(r => r.index === a.index)),
|
||||
[stopsAlongKm, rides],
|
||||
)
|
||||
const anchorKm = useMemo(() => {
|
||||
if (!section || !day || section.dayId !== day.dayId) return null
|
||||
return anchors.find(a => a.kind === section.kind && a.index === section.index)?.alongKm ?? null
|
||||
|
||||
@@ -849,3 +849,165 @@ 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-044: an edited timetable changes the ride minutes without a routing round, and the chain is not late for it', async () => {
|
||||
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 }, { id: 2, at: BERLIN, dwell: 0 }]
|
||||
const { result, rerender } = renderHook(
|
||||
({ reservations }) => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], reservations),
|
||||
{ initialProps: { reservations: [flight()] } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[0].legs[1]).toMatchObject({ mode: 'flight', duration: 70 * 60 })
|
||||
|
||||
// The same terminals, the same roads: the landing is now forty minutes after take-off.
|
||||
rerender({ reservations: [flight({ reservation_end_time: '14:00' })] })
|
||||
await waitFor(() => expect(result.current.days[0].legs[1]?.duration).toBe(40 * 60))
|
||||
const d = result.current.days[0]
|
||||
expect(d.legs[1]?.durationText).toBe('40 min')
|
||||
expect(d.schedule.entries.map(e => e.arrival)).toEqual(['09:00', '12:20', '14:00', '15:00'])
|
||||
expect(d.schedule.warnings.filter(w => w.code === 'late')).toEqual([])
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-045: an overnight ride is the join between its cards, and a moved landing moves it too', async () => {
|
||||
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
||||
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_connect_days: true } }))
|
||||
const days = [day(1, 1), day(2, 2)]
|
||||
const assignments = {
|
||||
...map(1, [{ id: 1, at: HAMBURG, time: '18:00', dwell: 0 }]),
|
||||
...map(2, [{ id: 2, at: BERLIN, dwell: 0 }]),
|
||||
}
|
||||
const overnight = (landing: string) => flight({ reservation_time: '22:00', reservation_end_time: landing, end_day_id: 2 })
|
||||
const { result, rerender } = renderHook(
|
||||
({ reservations }) => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], [], reservations),
|
||||
{ initialProps: { reservations: [overnight('07:00')] } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[1]?.arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600 })
|
||||
// The ride is derived, not fetched: the router saw the two roads and nothing else.
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
rerender({ reservations: [overnight('06:30')] })
|
||||
await waitFor(() => expect(result.current.days[1]?.arrivingLeg?.duration).toBe(8.5 * 3600))
|
||||
expect(result.current.days[1].schedule.entries[0].arrival).toBe('06:30')
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-042: a booking without located terminals, or a taxi, changes nothing', async () => {
|
||||
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
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, isCarrierMode, seatCarrierStops, standsAsDay, terminalAssignmentId, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
@@ -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,70 @@ 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
|
||||
|
||||
/**
|
||||
* The leg between the two terminals of every ride, keyed like a routed leg.
|
||||
*
|
||||
* Derived from the bookings rather than fetched, and kept out of the routing round on
|
||||
* purpose: that round only runs when the geometry changes, and a ride's minutes come
|
||||
* from its clocks. Filed inside the round, an edited departure time left the old
|
||||
* minutes in place until something else re-routed, so the chain reached the arrival
|
||||
* terminal late for a flight that had been moved earlier.
|
||||
*/
|
||||
const rideLegs = useMemo(() => {
|
||||
const terminals = new Map<number, { stop: RoadtripStop; dayNumber: number }>()
|
||||
for (const day of storedDays)
|
||||
for (const stop of day.stops)
|
||||
if (stop.carrier) terminals.set(stop.assignmentId, { stop, dayNumber: day.dayNumber })
|
||||
const out: Record<string, RoutedLeg> = {}
|
||||
for (const seam of rideSeams) {
|
||||
if (seam.kind !== 'ride') continue
|
||||
const dep = terminals.get(terminalAssignmentId(seam.reservationId, 'departure'))
|
||||
const arr = terminals.get(terminalAssignmentId(seam.reservationId, 'arrival'))
|
||||
if (!dep || !arr) continue
|
||||
Object.assign(out, carrierLegsFor([dep.stop, arr.stop], seam.type, () => arr.dayNumber - dep.dayNumber, legKey))
|
||||
}
|
||||
return out
|
||||
}, [storedDays, rideSeams])
|
||||
|
||||
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
|
||||
// editing its notes must not fire a routing round.
|
||||
@@ -300,6 +330,17 @@ export function useRoadtripRoutes(
|
||||
* into a burst at the one host that refused it.
|
||||
*/
|
||||
const enqueueRun = (day: PlanDay, dayLegs: Record<string, RoutedLeg>, run: RoadtripStop[], mode: string): void => {
|
||||
// A ride is no road, so no router is asked. The two ends of a booked ride have
|
||||
// their leg in `rideLegs`, which keeps step with an edited timetable; a terminal
|
||||
// standing beside an ordinary stop is the other carrier-mode pair, and it still
|
||||
// needs a leg of its own. Without one `computeSchedule` loses its cursor there and
|
||||
// every arrival behind it goes blank. A run is one stored day, so its ends are
|
||||
// never days apart.
|
||||
if (isCarrierMode(mode)) {
|
||||
const legs = carrierLegsFor(run, mode, () => 0, legKey) ?? {}
|
||||
for (const [key, leg] of Object.entries(legs)) if (!rideLegs[key]) dayLegs[key] = leg
|
||||
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 +363,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 }))
|
||||
})
|
||||
|
||||
@@ -404,22 +443,29 @@ export function useRoadtripRoutes(
|
||||
// routes a single day; a road trip is every leg of every day at once, and the
|
||||
// public routing hosts answer that with 429 after the first handful. Results are
|
||||
// published as they land so the rail fills in instead of sitting empty.
|
||||
const publish = (): void => {
|
||||
setLegsByDay({ ...collected })
|
||||
setSnapByDay({ ...collectedSnaps })
|
||||
setMissedByDay({ ...collectedMisses })
|
||||
}
|
||||
void (async () => {
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
if (controller.signal.aborted) return
|
||||
const startedAt = performance.now()
|
||||
await tasks[i]()
|
||||
if (controller.signal.aborted) return
|
||||
setLegsByDay({ ...collected })
|
||||
setSnapByDay({ ...collectedSnaps })
|
||||
setMissedByDay({ ...collectedMisses })
|
||||
publish()
|
||||
// Only pace what actually went out. RouteCalculator answers a repeat from its
|
||||
// cache in well under a millisecond, and switching back into road trip mode is
|
||||
// all repeats — waiting a second between those made a warm view feel broken.
|
||||
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
|
||||
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, controller.signal)
|
||||
}
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
if (controller.signal.aborted) return
|
||||
// A plan that is nothing but rides asked for no road, and still replaces what
|
||||
// the last round left behind.
|
||||
if (!tasks.length) publish()
|
||||
setLoading(false)
|
||||
})()
|
||||
|
||||
return () => controller.abort()
|
||||
@@ -443,9 +489,10 @@ export function useRoadtripRoutes(
|
||||
// every routing round while `seamLegs` only ever grows, so a pair that used to sit on
|
||||
// a day boundary and now sits inside one day keeps an answer nobody re-asks for, and
|
||||
// that older answer was shaped by the vias of a stop that has since moved. Where both
|
||||
// exist the day run is the newer of the two, so it is the one to believe.
|
||||
return { ...seamLegs, ...out }
|
||||
}, [plan, legsByDay, seamLegs])
|
||||
// exist the day run is the newer of the two, so it is the one to believe. The rides
|
||||
// last: a ride is never a road, whatever was fetched for its pair.
|
||||
return { ...seamLegs, ...out, ...rideLegs }
|
||||
}, [plan, legsByDay, seamLegs, rideLegs])
|
||||
|
||||
// Which date each stop is actually reached on. Nothing is written to make it so; see
|
||||
// `nightSpill.ts`.
|
||||
@@ -464,6 +511,8 @@ export function useRoadtripRoutes(
|
||||
const seams = useMemo(() => {
|
||||
const out: { from: RoadtripStop; to: RoadtripStop; dayId: number }[] = []
|
||||
const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => {
|
||||
// The two ends of one ride: the leg between them is the booking's, never a road.
|
||||
if (rideLegs[legKey(from, to)]) return
|
||||
// Routed as part of a day's own run — that request is rebuilt whenever its vias
|
||||
// change, so there is nothing to catch up here.
|
||||
if (legsByDay[from.ownerDayId]?.[legKey(from, to)]) return
|
||||
@@ -497,7 +546,7 @@ export function useRoadtripRoutes(
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [chains, plan, quietDays, window, legsByDay, seamLegs, viasByDay, connectDays])
|
||||
}, [chains, plan, quietDays, window, legsByDay, seamLegs, rideLegs, viasByDay, connectDays])
|
||||
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';')
|
||||
/** When the last seam request went out, across every run of the effect below. */
|
||||
const lastSeamRequestAt = useRef(0)
|
||||
@@ -508,6 +557,22 @@ export function useRoadtripRoutes(
|
||||
void (async () => {
|
||||
for (const seam of seams) {
|
||||
if (controller.signal.aborted) return
|
||||
const mode = resolveLegMode(
|
||||
{ isPlace: true, leg_transport_mode: seam.from.legMode },
|
||||
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
|
||||
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
|
||||
)
|
||||
// No router knows a ride. A booked ride's own pair has its leg in `rideLegs` and
|
||||
// never reaches here; what is left is a terminal seamed to an ordinary stop on
|
||||
// another card, which is a join of no minutes rather than a road. Filed here,
|
||||
// above the pacing block, so it spends no request slot.
|
||||
if (isCarrierMode(mode)) {
|
||||
const key = legKey(seam.from, seam.to)
|
||||
const legs = carrierLegsFor([seam.from, seam.to], mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey)
|
||||
const leg = legs?.[key]
|
||||
if (leg) setSeamLegs(prev => ({ ...prev, [key]: { ...leg, shape: seamShape(seam.from, viasByDay) } }))
|
||||
continue
|
||||
}
|
||||
// Paced before the request, against a clock that outlives this effect.
|
||||
//
|
||||
// Waiting AFTER one instead spaced nothing: storing a seam changes
|
||||
@@ -522,19 +587,12 @@ export function useRoadtripRoutes(
|
||||
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
lastSeamRequestAt.current = performance.now()
|
||||
const mode = resolveLegMode(
|
||||
{ isPlace: true, leg_transport_mode: seam.from.legMode },
|
||||
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
|
||||
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
|
||||
)
|
||||
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 },
|
||||
|
||||
@@ -644,11 +644,11 @@ describe('IntegrationsTab', () => {
|
||||
expect(createBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-032: error toast shown when create OAuth client fails', async () => {
|
||||
it('FE-COMP-INTEGRATIONS-032: a refused client registration toasts the reason the server gave', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/oauth/clients', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||
HttpResponse.json({ error: 'Redirect URI must use HTTPS, loopback HTTP, or a private custom scheme: http://192.168.1.5/cb' }, { status: 400 })
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
@@ -657,9 +657,24 @@ describe('IntegrationsTab', () => {
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
|
||||
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://192.168.1.5/cb');
|
||||
await user.click(screen.getByRole('button', { name: /Register Client/i }));
|
||||
expect(await screen.findByText(/Redirect URI must use HTTPS/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Failed to register OAuth client')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-032b: a failure the server says nothing about keeps the generic text', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/oauth/clients', () => HttpResponse.error()));
|
||||
enableMcp();
|
||||
render(<><ToastContainer /><IntegrationsTab /></>);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
|
||||
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
|
||||
await user.click(screen.getByRole('button', { name: /Register Client/i }));
|
||||
expect(await screen.findByText(/Failed to register/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Failed to register OAuth client')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import ApiKeysSection from './ApiKeysSection'
|
||||
import { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../api/oauthScopes'
|
||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
|
||||
interface OAuthPreset {
|
||||
id: string
|
||||
@@ -266,8 +267,11 @@ function useIntegrations() {
|
||||
setOauthNewUris('')
|
||||
setOauthNewScopes([])
|
||||
setOauthIsMachine(false)
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.createError'))
|
||||
} catch (err) {
|
||||
// The server names the rule that refused the client (a scope, the
|
||||
// ten-client cap, a redirect URI it will not take); swallowing it left
|
||||
// "Failed to register OAuth client" and nothing to act on.
|
||||
toast.error(getApiErrorMessage(err, t('settings.oauth.toast.createError')))
|
||||
} finally {
|
||||
setOauthCreating(false)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-RN-001 to FE-RN-014
|
||||
// FE-RN-001 to FE-RN-016
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -209,4 +209,15 @@ describe('ReleaseNoticeModal', () => {
|
||||
// The places card keeps the pill: that one is about the index.
|
||||
expect(cards[0].querySelector('.trek-mark .pill-bg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-RN-016: the close button belongs to the panel, not the note', () => {
|
||||
// Below 1080px the panel scrolls and the note is its second screen, so the
|
||||
// stylesheet sticks the button to the panel's own top edge. That only works
|
||||
// while it is a child of .rn-panel; put it back in the note and it scrolls a
|
||||
// whole release half out of reach with every CSS assertion still green.
|
||||
renderModal(releaseNotice())
|
||||
const close = screen.getByRole('button', { name: 'Close' })
|
||||
expect(close.parentElement?.classList.contains('rn-panel')).toBe(true)
|
||||
expect(close.closest('.rn-note')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,6 +64,14 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
|
||||
aria-describedby={bodyId}
|
||||
className="rn-panel"
|
||||
>
|
||||
{/* A child of the panel, not the note: stacked, the panel scrolls and the
|
||||
note is its second screen, so the button sticks to the panel's edge. */}
|
||||
{notice.dismissible && (
|
||||
<button type="button" className="rn-close" onClick={onDismiss} aria-label={t('common.close')}>
|
||||
<X size={17} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ── Left: the release ─────────────────────────────────────────── */}
|
||||
<div className="rn-release">
|
||||
<div className="rn-release-grain" aria-hidden="true" />
|
||||
@@ -127,12 +135,6 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
|
||||
|
||||
{/* ── Right: the note ───────────────────────────────────────────── */}
|
||||
<div className="rn-note">
|
||||
{notice.dismissible && (
|
||||
<button type="button" className="rn-close" onClick={onDismiss} aria-label={t('common.close')}>
|
||||
<X size={17} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="rn-note-body">
|
||||
<div className="rn-note-eyebrow">{t(release.note.eyebrowKey)}</div>
|
||||
<h3 className="rn-note-title">{t(release.note.titleKey)}</h3>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
.rn-close { box-sizing: content-box; }
|
||||
|
||||
.rn-panel {
|
||||
position: relative;
|
||||
width: min(1420px, calc(100% - 2px));
|
||||
max-height: 100%;
|
||||
display: grid;
|
||||
@@ -67,9 +68,68 @@
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
/* Below the two-column breakpoint the halves stack and the panel scrolls. */
|
||||
/* The panel's, not the note's: in the stacked layout below it has to outlast the
|
||||
release half, which scrolls by first. */
|
||||
.rn-close {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
/* Logical, so the X sits at the reading end in Arabic too. With the physical
|
||||
right it stayed over the release half there, which the note's palette below
|
||||
is not made for, and the stacked layout put it at the other end anyway. */
|
||||
inset-inline-end: 22px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-faint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.rn-close:hover { background: var(--bg-selected); color: var(--text-primary); }
|
||||
|
||||
/* Below the two-column breakpoint the halves stack and the panel scrolls. The
|
||||
rows must go back to auto: with the single minmax row from above, the note took
|
||||
the whole panel and the release half was left with nothing but its padding. */
|
||||
@media (max-width: 1080px) {
|
||||
.rn-panel { grid-template-columns: 1fr; overflow-y: auto; }
|
||||
.rn-panel { grid-template-columns: 1fr; grid-template-rows: auto auto; overflow-y: auto; }
|
||||
.rn-release { grid-area: 1 / 1; }
|
||||
.rn-note { grid-area: 2 / 1; }
|
||||
.rn-release, .rn-release-inner, .rn-note-body { overflow: visible; }
|
||||
/* The X used to sit in the note, a whole release half below the top edge, and
|
||||
at a tablet's width the backdrop is gone too. As a grid item over both rows
|
||||
it sticks 22px under the top edge for the entire scroll, over either half. */
|
||||
.rn-close {
|
||||
position: sticky;
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 1;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
margin: 22px;
|
||||
/* At the top of the scroll it hangs over the release half, which is dark in
|
||||
both themes. The note's palette is a near-transparent fill and secondary
|
||||
ink: in the light theme that is a slate glyph on near black, roughly 1.8:1,
|
||||
so the only way to dismiss the notice on a tablet was invisible. An opaque
|
||||
chip with primary ink carries over either half, the way .rn-support below
|
||||
gets a solid band once it sticks. */
|
||||
background: color-mix(in srgb, var(--bg-card) 92%, var(--text-primary));
|
||||
border-color: var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 2px 8px oklch(0 0 0 / 0.25);
|
||||
}
|
||||
/* Once the note has scrolled into view, the ask stays at the bottom edge. The
|
||||
band's usual tint is translucent, so it gets a solid one here. */
|
||||
.rn-support {
|
||||
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,
|
||||
@@ -549,26 +609,6 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rn-close {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
right: 22px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-faint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.rn-close:hover { background: var(--bg-selected); color: var(--text-primary); }
|
||||
|
||||
.rn-note-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -581,6 +621,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;
|
||||
|
||||
@@ -12,10 +12,108 @@ describe('release notice css', () => {
|
||||
const notice = read('src/components/SystemNotices/releaseNotice.css')
|
||||
const tokens = read('src/index.css')
|
||||
|
||||
const block = (selector: string): string => {
|
||||
const at = notice.indexOf(selector)
|
||||
const within = (source: string, selector: string): string => {
|
||||
const at = source.indexOf(selector)
|
||||
expect(at, `${selector} missing from releaseNotice.css`).toBeGreaterThan(-1)
|
||||
return notice.slice(at, notice.indexOf('}', at) + 1)
|
||||
return source.slice(at, source.indexOf('}', at) + 1)
|
||||
}
|
||||
|
||||
const block = (selector: string): string => within(notice, selector)
|
||||
|
||||
// A half is named twice: indented in the stacked block, where it is only placed
|
||||
// in the grid, and at the top level, where it is painted. Anchor on the newline
|
||||
// to get the second.
|
||||
const painted = (selector: string): string => within(notice, `\n${selector}`)
|
||||
|
||||
const stacked = (): string => {
|
||||
const at = notice.indexOf('@media (max-width: 1080px)')
|
||||
expect(at, 'the stacked block').toBeGreaterThan(-1)
|
||||
return notice.slice(at, notice.indexOf('\n}', at))
|
||||
}
|
||||
|
||||
// How many rules deep a position sits, comments taken out so a brace in prose
|
||||
// cannot shift the count. Zero means the rule applies at every width.
|
||||
const depthAt = (index: number): number => {
|
||||
let depth = 0
|
||||
for (const ch of notice.slice(0, index).replace(/\/\*[\s\S]*?\*\//g, '')) {
|
||||
if (ch === '{') depth += 1
|
||||
else if (ch === '}') depth -= 1
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
const decl = (rule: string, prop: string): string => {
|
||||
const found = new RegExp(`(?:^|[;{\\n])\\s*${prop}:\\s*([^;]+);`).exec(rule)
|
||||
expect(found, `${prop} missing from ${rule.slice(0, 44)}`).not.toBeNull()
|
||||
return found![1].trim()
|
||||
}
|
||||
|
||||
interface Rgba { r: number; g: number; b: number; a: number }
|
||||
|
||||
// The custom properties of one theme, so a declaration can be resolved the way
|
||||
// the browser resolves it instead of being taken on trust.
|
||||
const theme = (opener: string): Record<string, string> => {
|
||||
const at = tokens.indexOf(opener)
|
||||
expect(at, `${opener} missing from index.css`).toBeGreaterThan(-1)
|
||||
const body = tokens.slice(at, tokens.indexOf('\n}', at))
|
||||
const map: Record<string, string> = {}
|
||||
for (const [, token, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) map[token] = value.trim()
|
||||
return map
|
||||
}
|
||||
|
||||
const rgba = (value: string, vars: Record<string, string>): Rgba => {
|
||||
const v = value.trim()
|
||||
const ref = /^var\((--[\w-]+)\)$/.exec(v)
|
||||
if (ref) {
|
||||
const token = vars[ref[1]]
|
||||
expect(token, `${ref[1]} missing from index.css`).toBeDefined()
|
||||
return rgba(token, vars)
|
||||
}
|
||||
const mix = /^color-mix\(in srgb,\s*(.+?)\s+([\d.]+)%,\s*(.+)\)$/.exec(v)
|
||||
if (mix) {
|
||||
const share = Number(mix[2]) / 100
|
||||
const first = rgba(mix[1], vars)
|
||||
const second = rgba(mix[3], vars)
|
||||
const blend = (x: number, y: number): number => x * share + y * (1 - share)
|
||||
return {
|
||||
r: blend(first.r, second.r),
|
||||
g: blend(first.g, second.g),
|
||||
b: blend(first.b, second.b),
|
||||
a: blend(first.a, second.a)
|
||||
}
|
||||
}
|
||||
const hex = /^#([0-9a-f]{6})$/i.exec(v)
|
||||
if (hex) {
|
||||
return {
|
||||
r: parseInt(hex[1].slice(0, 2), 16),
|
||||
g: parseInt(hex[1].slice(2, 4), 16),
|
||||
b: parseInt(hex[1].slice(4, 6), 16),
|
||||
a: 1
|
||||
}
|
||||
}
|
||||
const fn = /^rgba?\(([^)]+)\)$/.exec(v)
|
||||
expect(fn, `${value} is not a colour this test can resolve`).not.toBeNull()
|
||||
const parts = fn![1].split(',').map(Number)
|
||||
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }
|
||||
}
|
||||
|
||||
const over = (front: Rgba, back: Rgba): Rgba => ({
|
||||
r: front.r * front.a + back.r * (1 - front.a),
|
||||
g: front.g * front.a + back.g * (1 - front.a),
|
||||
b: front.b * front.a + back.b * (1 - front.a),
|
||||
a: 1
|
||||
})
|
||||
|
||||
const contrast = (one: Rgba, other: Rgba): number => {
|
||||
const luminance = (c: Rgba): number => {
|
||||
const channel = (raw: number): number => {
|
||||
const v = raw / 255
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b)
|
||||
}
|
||||
const [light, dark] = [luminance(one), luminance(other)].sort((a, b) => b - a)
|
||||
return (light + 0.05) / (dark + 0.05)
|
||||
}
|
||||
|
||||
const step = (name: string): number => {
|
||||
@@ -36,4 +134,71 @@ 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/)
|
||||
})
|
||||
|
||||
it('FE-COMP-RELEASENOTICECSS-004: stacked, the close button stays on screen for the whole scroll', () => {
|
||||
// Below 1080px the panel scrolls as one column with the release half first.
|
||||
// The X lived in the note, so on a tablet it was a whole release half below
|
||||
// the fold, and at 900px and under there is no backdrop to tap instead. It
|
||||
// is a grid item spanning both rows now, stuck to the panel's top edge; the
|
||||
// base rule is absolute and must come first or it wins the cascade.
|
||||
const at = notice.indexOf('@media (max-width: 1080px)')
|
||||
const base = notice.indexOf('.rn-close {\n position: absolute')
|
||||
expect(base, 'the absolute close rule').toBeGreaterThan(-1)
|
||||
expect(at).toBeGreaterThan(base)
|
||||
const close = within(stacked(), '.rn-close {')
|
||||
expect(close).toMatch(/position:\s*sticky/)
|
||||
expect(close).toMatch(/grid-row:\s*1 \/ -1/)
|
||||
// Both halves need a definite cell, or auto-placement steps around the button.
|
||||
expect(stacked()).toMatch(/\.rn-release \{ grid-area: 1 \/ 1; \}/)
|
||||
expect(stacked()).toMatch(/\.rn-note \{ grid-area: 2 \/ 1; \}/)
|
||||
expect(block('.rn-panel {')).toMatch(/position:\s*relative/)
|
||||
// Sticky without an offset never sticks, and the offset is the base rule's:
|
||||
// scope that into a width query of its own and the button silently scrolls
|
||||
// away again with every assertion above still green.
|
||||
expect(decl(notice.slice(base), 'top'), 'the close button has no offset to stick to').toMatch(/^\d+px$/)
|
||||
expect(depthAt(base), 'the offset the sticky rule leans on sits inside a query').toBe(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-RELEASENOTICECSS-005: stacked, the close button reads over the release half too', () => {
|
||||
// Stuck to the panel's top edge the X hangs over .rn-release, a brand panel
|
||||
// that is dark in both themes, but it kept the palette it had on the white
|
||||
// note: a near-transparent fill under secondary ink. Resolved against the real
|
||||
// tokens that is a slate glyph on near black in the light theme, about 1.8:1,
|
||||
// and below 1080px this button is the only way out of the notice. Both halves
|
||||
// are one scroll, so the chip has to carry over either.
|
||||
const gradient = decl(painted('.rn-release {'), 'background')
|
||||
const backdrops = [...gradient.matchAll(/#[0-9a-f]{6}/gi)].map(match => match[0])
|
||||
expect(backdrops.length, 'the release half paints no gradient stops').toBeGreaterThan(1)
|
||||
backdrops.push(decl(painted('.rn-note {'), 'background'))
|
||||
|
||||
const themes = [
|
||||
{ name: 'light', vars: theme(':root {') },
|
||||
{ name: 'dark', vars: theme('.dark {') }
|
||||
]
|
||||
const states = [
|
||||
{ name: 'at rest', rule: within(stacked(), '.rn-close {') },
|
||||
{ name: 'hovered', rule: block('.rn-close:hover {') }
|
||||
]
|
||||
|
||||
for (const { name, vars } of themes) {
|
||||
for (const state of states) {
|
||||
const glyph = rgba(decl(state.rule, 'color'), vars)
|
||||
for (const behind of backdrops) {
|
||||
const chip = over(rgba(decl(state.rule, 'background'), vars), rgba(behind, vars))
|
||||
expect(contrast(glyph, chip), `${name}, ${state.name}, over ${behind}`).toBeGreaterThanOrEqual(4.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1375,6 +1375,141 @@ 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); }
|
||||
/* Fingers, not a pointer: the phone Collab tab mounts this same chip. 22px targets
|
||||
a pixel apart put delete one slip away from pin, so the hit box grows to the
|
||||
30px at a 34px pitch the phone Notes tab uses. The vertical margin gives the
|
||||
height back, the chip stays 36px. */
|
||||
@media (hover: none) {
|
||||
.collab-link-chip__actions { opacity: 1; gap: 4px; }
|
||||
.collab-link-chip__action { width: 30px; height: 30px; margin: -4px 0; }
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { AlertTriangle, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { adminApi, authApi } from '../../../api/client'
|
||||
import { getApiErrorMessage } from '../../../types'
|
||||
import { placesGoogleOnlyHint } from '../../../utils/placeSource'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import type { useAdmin } from '../../../pages/admin/useAdmin'
|
||||
import MToggle from '../../components/MToggle'
|
||||
@@ -34,6 +35,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 +352,17 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MAdminRow
|
||||
title={t('admin.placesGoogleOnly.title')}
|
||||
hint={t(placesGoogleOnlyHint(hasMapsKey, placesProvider))}
|
||||
trailing={
|
||||
<MToggle
|
||||
checked={placesGoogleOnly}
|
||||
ariaLabel={t('admin.placesGoogleOnly.title')}
|
||||
onChange={() => handleTogglePlacesGoogleOnly()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</MBlockDisclosure>
|
||||
</MProviderBlock>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
|
||||
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
|
||||
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
|
||||
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
|
||||
import { photoUrl } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
|
||||
import { useJourneyStore } from '../../../store/journeyStore'
|
||||
import { useAddonStore } from '../../../store/addonStore'
|
||||
@@ -388,7 +388,7 @@ export default function MJourneyDetail() {
|
||||
onClick={() => openLightbox(gallery, i)}
|
||||
className="relative aspect-square overflow-hidden rounded-[14px]"
|
||||
>
|
||||
{photo.media_type === 'video' && !photo.thumbnail_path ? (
|
||||
{posterlessVideo(photo) ? (
|
||||
<span className="block h-full w-full bg-[color:var(--m-ic)]" />
|
||||
) : (
|
||||
<img src={photoUrl(photo, 'thumbnail')} alt={photo.caption || ''} loading="lazy" className="h-full w-full object-cover" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { localIsoDate } from '../../../utils/localDate'
|
||||
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff } from 'lucide-react'
|
||||
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MToggle from '../../components/MToggle'
|
||||
@@ -16,7 +16,7 @@ import { getCurrentPositionOnce } from '../../../hooks/useGeolocation'
|
||||
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
|
||||
import { useAddonStore } from '../../../store/addonStore'
|
||||
import { photoUrl, geoOnceErrorKey, isValidGeoPoint } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { photoUrl, posterlessVideo, geoOnceErrorKey, isValidGeoPoint } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import JournalBody from '../../../components/Journey/JournalBody'
|
||||
import { ProviderPicker, type ProviderPhotoGroup } from '../../../components/Journey/JourneyDetailPageProviderPicker'
|
||||
import { journeyWeatherCategory, MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
|
||||
@@ -33,6 +33,18 @@ interface LocationResult {
|
||||
|
||||
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
|
||||
|
||||
// A clip with no poster to show: the tinted tile and play mark the gallery grid
|
||||
// gives it, since an <img> asked for its thumbnail draws the broken glyph (#2341).
|
||||
function ClipTile() {
|
||||
return (
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-[color:var(--m-ic)]">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur">
|
||||
<Play size={12} className="ml-[1px]" fill="currentColor" />
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface MJourneyEntrySheetProps {
|
||||
entry: JourneyEntry
|
||||
galleryPhotos: GalleryPhoto[]
|
||||
@@ -592,7 +604,11 @@ export default function MJourneyEntrySheet({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img src={photoUrl(gp)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
|
||||
{posterlessVideo(gp) ? (
|
||||
<ClipTile />
|
||||
) : (
|
||||
<img src={photoUrl(gp)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{availableGalleryPhotos.length === 0 && (
|
||||
@@ -610,7 +626,11 @@ export default function MJourneyEntrySheet({
|
||||
<div className="mt-[10px] flex flex-wrap gap-2">
|
||||
{photos.map((p, idx) => (
|
||||
<div key={p.id} className="relative h-16 w-16 overflow-hidden rounded-[13px]">
|
||||
<img src={photoUrl(p)} alt="" className="h-full w-full object-cover" />
|
||||
{posterlessVideo(p) ? (
|
||||
<ClipTile />
|
||||
) : (
|
||||
<img src={photoUrl(p)} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
{!readOnly && idx > 0 && photos.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -11,6 +11,7 @@ import MChip from '../../components/MChip'
|
||||
import { MSetCard, MSetEyebrow, MSetInput, MSetTextarea, MSetButton, MSetHint, MSetRow } from './MSettingsUi'
|
||||
import MConfirmSheet from './MConfirmSheet'
|
||||
import MSegmented from '../../components/MSegmented'
|
||||
import { getApiErrorMessage } from '../../../utils/apiError'
|
||||
|
||||
interface OAuthPreset {
|
||||
id: string
|
||||
@@ -188,8 +189,11 @@ export default function MSettingsMcp() {
|
||||
setNewUris('')
|
||||
setNewScopes([])
|
||||
setIsMachine(false)
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.createError'))
|
||||
} catch (err) {
|
||||
// The server names the rule that refused the client (a scope, the
|
||||
// ten-client cap, a redirect URI it will not take); swallowing it left
|
||||
// "Failed to register OAuth client" and nothing to act on.
|
||||
toast.error(getApiErrorMessage(err, t('settings.oauth.toast.createError')))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
@@ -381,7 +381,11 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
dayOrderMap={planner.dayOrderMap}
|
||||
reservations={planner.reservations}
|
||||
showReservationStats={true}
|
||||
visibleConnectionIds={planner.visibleConnections}
|
||||
// On the stage the rides the chain seams in draw as their booking's own arc,
|
||||
// toggled on or not, the way the desktop map does it in road trip mode. The
|
||||
// ride's leg carries no geometry, so without this the road ended at the
|
||||
// departure terminal and picked up again at the arrival with nothing between.
|
||||
visibleConnectionIds={onStage ? planner.roadtripConnections : planner.visibleConnections}
|
||||
// Transport overlay tap → the mobile transport detail sheet (desktop
|
||||
// routes this through mapTransportDetail into the day sidebar instead).
|
||||
onReservationClick={(rid: number) => shell.openSheet('transport', { reservationId: rid })}
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
const { t, trip, can } = planner
|
||||
const canEdit = can('day_edit', trip)
|
||||
const editing = shell.mode === 'edit' && canEdit
|
||||
const canEditPlaces = can('place_edit', trip)
|
||||
// Per-segment travel mode (#1281): tap a connector → pick the leg's mode.
|
||||
const legMenu = useContextMenu()
|
||||
const modeIcon = (key: string) => (key === 'walking' ? Footprints : key.startsWith('plugin:') ? Zap : Car)
|
||||
@@ -202,7 +203,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
reorder={reorderFor(row.item)}
|
||||
drag={dragFor(row)}
|
||||
onOpen={() => openPlace(row.assignment)}
|
||||
onEdit={() => tl.editAssignment(row.assignment)}
|
||||
onEdit={canEditPlaces ? () => tl.editAssignment(row.assignment) : undefined}
|
||||
onRemove={() => tl.removeAssignment(row.assignment)}
|
||||
/>
|
||||
{dayScheduleFor('assignment', row.assignment.id)}
|
||||
|
||||
@@ -124,7 +124,7 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
|
||||
reorder: ReactNode
|
||||
drag?: RowDrag
|
||||
onOpen: () => void
|
||||
onEdit: () => void
|
||||
onEdit?: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const { t } = chrome
|
||||
@@ -210,9 +210,11 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
|
||||
</div>
|
||||
{chrome.editing && (
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
<ActionCircle label={t('common.edit')} onClick={onEdit}>
|
||||
<Pencil size={14} strokeWidth={2} />
|
||||
</ActionCircle>
|
||||
{onEdit && (
|
||||
<ActionCircle label={t('common.edit')} onClick={onEdit}>
|
||||
<Pencil size={14} strokeWidth={2} />
|
||||
</ActionCircle>
|
||||
)}
|
||||
<ActionCircle label={t('planner.removeFromDay')} onClick={onRemove}>
|
||||
<X size={14} strokeWidth={2} />
|
||||
</ActionCircle>
|
||||
|
||||
@@ -3,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 { bookingOpens, carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
|
||||
import { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings'
|
||||
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
|
||||
import { formatDistance } from '../../../../utils/units'
|
||||
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,108 @@ 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. A chip that
|
||||
* opens is a button; one this reader may not open stays a plain chip, the rule the rail
|
||||
* follows too (`bookingOpens`).
|
||||
*/
|
||||
export function RtBookingChips({ bookings, chrome, canEdit, onOpen }: {
|
||||
bookings: Reservation[]
|
||||
chrome: RowChrome
|
||||
/** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
|
||||
canEdit: boolean
|
||||
onOpen?: (reservation: Reservation) => void
|
||||
}) {
|
||||
return (
|
||||
<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 && bookingOpens(r, canEdit) ? (
|
||||
<button key={r.id} type="button" onClick={() => onOpen(r)} className={`${cls} text-start`}>{body}</button>
|
||||
) : (
|
||||
<span key={r.id} className={cls}>{body}</span>
|
||||
)
|
||||
})}
|
||||
</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, useCallback, 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,9 @@ 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 { getDayOrder } from '../../../../utils/dayOrder'
|
||||
import type { Reservation } from '../../../../types'
|
||||
|
||||
/**
|
||||
* The road trip tab: one day of the drive, as a chain or on the map.
|
||||
@@ -77,7 +80,37 @@ 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 })
|
||||
}
|
||||
|
||||
// Where each day stands in the trip, so a booking spanning several days is listed on
|
||||
// the days between its ends as well.
|
||||
const dayOrder = useCallback((dayId: number) => {
|
||||
const stored = planner.days.find(d => d.id === dayId)
|
||||
return stored ? getDayOrder(stored, planner.days) : null
|
||||
}, [planner.days])
|
||||
// The bookings under the stage's stops and the ones the stage has for no stop, keyed
|
||||
// by the stop's assignment because the rows carry no index. Opened the way the place
|
||||
// sheet opens its linked bookings: a transport in its sheet, anything else in its
|
||||
// editor.
|
||||
const bookings = useMemo(() => {
|
||||
const byIndex = stage ? dayBookings(stage, planner.reservations, dayOrder) : null
|
||||
const atStop = new Map<number, Reservation[]>()
|
||||
if (stage && byIndex) for (const [i, list] of byIndex.atStop) atStop.set(stage.stops[i]!.assignmentId, list)
|
||||
return { atStop, loose: byIndex?.loose ?? [] }
|
||||
}, [stage, planner.reservations, dayOrder])
|
||||
// A chip that may not be opened is not a button in the first place (`bookingOpens`),
|
||||
// so nothing is turned away silently here.
|
||||
const canEditBookings = planner.can('reservation_edit', planner.trip)
|
||||
const openBooking = (res: Reservation) => {
|
||||
if (planner.TRANSPORT_TYPES.has(res.type)) {
|
||||
shell.openSheet('transport', { reservationId: res.id })
|
||||
return
|
||||
}
|
||||
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 +257,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} canEdit={canEditBookings} onOpen={openBooking} />}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'leg') {
|
||||
@@ -256,6 +292,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 +318,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} canEdit={canEditBookings} onOpen={openBooking} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import MSheet from '../../../components/MSheet'
|
||||
import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeModal'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { INNER_CLS, TileHeader } from './MTripSheetUi'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
@@ -20,6 +21,9 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
// The PDF is built outside React, so it cannot read this itself (#2066).
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
// Fed the way the desktop dialog feeds it: the store's assignments and the switch,
|
||||
// and the export applies the day plan's filter itself, so both shells print the same.
|
||||
const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, planner.tripId)
|
||||
const open = shell.sheet?.id === 'export'
|
||||
const dayNotes = useTripStore(s => s.dayNotes)
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
@@ -44,7 +48,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
trip: planner.trip,
|
||||
days: planner.days,
|
||||
places: planner.places,
|
||||
assignments: planner.assignments,
|
||||
assignments: planner.storedAssignments,
|
||||
categories: planner.categories,
|
||||
dayNotes: flatNotes,
|
||||
reservations: planner.reservations,
|
||||
@@ -52,6 +56,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
locale,
|
||||
timeFormat,
|
||||
distanceUnit,
|
||||
showServiceStops,
|
||||
})
|
||||
} catch (e) {
|
||||
planner.toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`)
|
||||
|
||||
@@ -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, selectGoogleHoldsSlot, 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 googleAnswers = useAuthStore(selectGoogleHoldsSlot)
|
||||
|
||||
const setResolving = useCallback(
|
||||
(v: boolean) => {
|
||||
@@ -168,8 +172,10 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
const trimmed = query.trim()
|
||||
const handleSearch = async (provider?: 'google') => {
|
||||
// The retry sends the query the list came from, as the desktop form does:
|
||||
// the list stays on screen while the field is edited or cleared.
|
||||
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : query.trim()
|
||||
if (!trimmed) return
|
||||
setSuggestions([])
|
||||
|
||||
@@ -183,7 +189,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
|
||||
setResolving(true)
|
||||
try {
|
||||
if (isMapUrl(trimmed)) {
|
||||
if (!provider && isMapUrl(trimmed)) {
|
||||
const resolved = await mapsApi.resolveUrl(trimmed)
|
||||
if (resolved.lat && resolved.lng) {
|
||||
onPick({
|
||||
@@ -200,9 +206,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 +291,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 +339,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, where Google holds the key slot. */}
|
||||
{results.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
|
||||
<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'
|
||||
@@ -261,6 +261,7 @@ function BookingCard({ res, planner, canEdit, compact }: {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
|
||||
|
||||
<MConfirmSheet
|
||||
open={confirmDelete}
|
||||
|
||||
@@ -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,
|
||||
@@ -279,6 +279,7 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
|
||||
|
||||
<MConfirmSheet
|
||||
open={confirmDelete}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PAGE-PUBLICJOURNEY-001 to FE-PAGE-PUBLICJOURNEY-010
|
||||
// FE-PAGE-PUBLICJOURNEY-001 to FE-PAGE-PUBLICJOURNEY-023
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
|
||||
@@ -580,4 +580,42 @@ describe('JourneyPublicPage', () => {
|
||||
const badge = Array.from(shibuya.querySelectorAll('span')).find(s => /^\d+$/.test(s.textContent || ''));
|
||||
expect(badge?.textContent).toBe('1');
|
||||
});
|
||||
|
||||
// The share route resolves a photo the way the journey's own does, so a clip that
|
||||
// came up without a poster 404s here too, and an <img> pointed at it drew the
|
||||
// broken-image glyph on every shared layout (#2341).
|
||||
it('FE-PAGE-PUBLICJOURNEY-023: a shared clip without a poster is a black tile, with a poster its poster', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bare = { id: 200, entry_id: 20, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.mp4', thumbnail_path: null, media_type: 'video', caption: null };
|
||||
const withPoster = { id: 201, entry_id: 21, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.mp4', thumbnail_path: 'journey/b.jpg', media_type: 'video', caption: null };
|
||||
const entry = (id: number, photo: typeof bare) => ({
|
||||
id, title: `Clip ${id}`, story: null, entry_date: '2026-03-15', entry_time: null,
|
||||
location_name: null, location_lat: null, location_lng: null, mood: null, weather: null, pros_cons: null,
|
||||
photos: [photo],
|
||||
});
|
||||
server.use(http.get('/api/public/journey/test-share-token', () => HttpResponse.json({
|
||||
...mockJourneyData,
|
||||
entries: [entry(20, bare), entry(21, withPoster)],
|
||||
gallery: [
|
||||
{ ...bare, journey_id: 1, shared: 1, sort_order: 0, created_at: 0 },
|
||||
{ ...withPoster, journey_id: 1, shared: 1, sort_order: 1, created_at: 0 },
|
||||
],
|
||||
stats: { entries: 2, photos: 2, places: 0 },
|
||||
})));
|
||||
const bareSrc = 'img[src="/api/public/journey/test-share-token/photos/200/thumbnail"]';
|
||||
const posterSrc = 'img[src="/api/public/journey/test-share-token/photos/201/thumbnail"]';
|
||||
|
||||
render(<JourneyPublicPage />);
|
||||
await waitFor(() => expect(screen.getByText('Clip 20')).toBeInTheDocument());
|
||||
|
||||
expect(document.querySelector(bareSrc)).toBeNull();
|
||||
expect(document.querySelector(posterSrc)).toBeInTheDocument();
|
||||
expect(document.querySelectorAll('svg.lucide-play')).toHaveLength(2);
|
||||
|
||||
await user.click(screen.getAllByRole('button').find(btn => /gallery/i.test(btn.textContent || ''))!);
|
||||
await waitFor(() => expect(document.querySelectorAll('.grid svg.lucide-play')).toHaveLength(2));
|
||||
|
||||
expect(document.querySelector(bareSrc)).toBeNull();
|
||||
expect(document.querySelector(posterSrc)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import EmptyState from '../components/shared/EmptyState';
|
||||
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { formatLocationName } from '../utils/formatters';
|
||||
import { posterlessVideo } from './journeyDetail/JourneyDetailPage.helpers';
|
||||
import { useJourneyPublic } from './journeyPublic/useJourneyPublic';
|
||||
|
||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
@@ -73,6 +74,13 @@ function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail'
|
||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`;
|
||||
}
|
||||
|
||||
// The share route answers 404 for the thumbnail of a clip that has no poster, and
|
||||
// an <img> pointed at it draws the broken-image glyph (#2341). The black ground
|
||||
// the layouts give a video stands in for the picture.
|
||||
function ClipTile() {
|
||||
return <span className="block h-full w-full bg-black" />;
|
||||
}
|
||||
|
||||
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
|
||||
const date = new Date(d + 'T00:00:00');
|
||||
return {
|
||||
@@ -251,12 +259,14 @@ export default function JourneyPublicPage() {
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
|
||||
>
|
||||
<div className={`relative h-64 w-full ${photos[0].media_type === 'video' ? 'bg-black' : ''}`}>
|
||||
<img
|
||||
src={photoUrl(photos[0], token!, photos[0].media_type === 'video' ? 'thumbnail' : 'original')}
|
||||
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
alt=""
|
||||
/>
|
||||
{!posterlessVideo(photos[0]) && (
|
||||
<img
|
||||
src={photoUrl(photos[0], token!, photos[0].media_type === 'video' ? 'thumbnail' : 'original')}
|
||||
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
|
||||
{photos[0].media_type === 'video' && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
@@ -301,12 +311,14 @@ export default function JourneyPublicPage() {
|
||||
}`}
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(p, token!, 'thumbnail')}
|
||||
alt=""
|
||||
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
{!posterlessVideo(p) && (
|
||||
<img
|
||||
src={photoUrl(p, token!, 'thumbnail')}
|
||||
alt=""
|
||||
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{p.media_type === 'video' && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
@@ -327,12 +339,16 @@ export default function JourneyPublicPage() {
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photos[0], token!, 'thumbnail')}
|
||||
alt=""
|
||||
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
{posterlessVideo(photos[0]) ? (
|
||||
<ClipTile />
|
||||
) : (
|
||||
<img
|
||||
src={photoUrl(photos[0], token!, 'thumbnail')}
|
||||
alt=""
|
||||
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 flex-col" style={{ gap: 2 }}>
|
||||
<button
|
||||
@@ -340,24 +356,32 @@ export default function JourneyPublicPage() {
|
||||
className="min-h-0 flex-1 cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photos[1], token!, 'thumbnail')}
|
||||
alt=""
|
||||
className={`h-full w-full ${photos[1].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
{posterlessVideo(photos[1]) ? (
|
||||
<ClipTile />
|
||||
) : (
|
||||
<img
|
||||
src={photoUrl(photos[1], token!, 'thumbnail')}
|
||||
alt=""
|
||||
className={`h-full w-full ${photos[1].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="relative min-h-0 flex-1 cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photos[2], token!, 'thumbnail')}
|
||||
alt=""
|
||||
className={`h-full w-full ${photos[2].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
{posterlessVideo(photos[2]) ? (
|
||||
<ClipTile />
|
||||
) : (
|
||||
<img
|
||||
src={photoUrl(photos[2], token!, 'thumbnail')}
|
||||
alt=""
|
||||
className={`h-full w-full ${photos[2].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{photos.length > 3 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||
<span className="flex items-center gap-1 text-[13px] font-semibold text-white">
|
||||
@@ -529,15 +553,19 @@ export default function JourneyPublicPage() {
|
||||
})
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photo, token!, 'thumbnail')}
|
||||
className={`h-full w-full transition-transform hover:scale-105 ${photo.media_type === 'video'
|
||||
? 'object-contain bg-black'
|
||||
: 'object-cover'
|
||||
}`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
{posterlessVideo(photo) ? (
|
||||
<ClipTile />
|
||||
) : (
|
||||
<img
|
||||
src={photoUrl(photo, token!, 'thumbnail')}
|
||||
className={`h-full w-full transition-transform hover:scale-105 ${photo.media_type === 'video'
|
||||
? 'object-contain bg-black'
|
||||
: 'object-cover'
|
||||
}`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
{photo.media_type === 'video' && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur">
|
||||
@@ -825,7 +853,7 @@ export default function JourneyPublicPage() {
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
readOnly
|
||||
onEntryClick={(entry) => setViewingEntry(entry as any)}
|
||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/thumbnail`}
|
||||
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
|
||||
cartoApiKey={cartoApiKey}
|
||||
showMood={journey.show_mood !== 0}
|
||||
|
||||
@@ -269,8 +269,11 @@ describe('JourneyPublicPage wiring', () => {
|
||||
const { hook } = setup({ isMobile: true, desktopTwoColumn: false });
|
||||
expect(screen.getByTestId('mobile-timeline')).toBeInTheDocument();
|
||||
|
||||
// The cover card draws this URL in an <img>, so it has to be the poster, not
|
||||
// the file itself: /original is the clip for a video and a full-size JPEG for
|
||||
// a photo (#2341). The own-journey cover already asks for /thumbnail.
|
||||
const publicPhotoUrl = mocks.captured.mobileTimeline.publicPhotoUrl as (id: number) => string;
|
||||
expect(publicPhotoUrl(55)).toBe('/api/public/journey/tok-1/photos/55/original');
|
||||
expect(publicPhotoUrl(55)).toBe('/api/public/journey/tok-1/photos/55/thumbnail');
|
||||
|
||||
(mocks.captured.mobileTimeline.onEntryClick as (e: PublicEntry) => void)(buildEntry({ id: 9 }));
|
||||
expect(hook.setViewingEntry).toHaveBeenCalledWith(expect.objectContaining({ id: 9 }));
|
||||
|
||||
@@ -16,7 +16,9 @@ vi.mock('react-leaflet', () => ({
|
||||
),
|
||||
TileLayer: ({ url }: { url: string }) => <div data-testid="raster-tiles" data-url={url} />,
|
||||
Marker: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
Polyline: () => <div data-testid="route-line" />,
|
||||
Polyline: ({ positions }: { positions: [number, number][] }) => (
|
||||
<div data-testid="route-line" data-positions={JSON.stringify(positions)} />
|
||||
),
|
||||
Tooltip: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
useMap: () => ({
|
||||
fitBounds: vi.fn(),
|
||||
@@ -60,6 +62,10 @@ function renderSharedTrip(token: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// Leaflet is mocked, so every marker the last render drew is a recorded divIcon call.
|
||||
const divIconMock = () => L.divIcon as unknown as ReturnType<typeof vi.fn>;
|
||||
const iconHtml = () => divIconMock().mock.calls.map(c => String(c[0].html));
|
||||
|
||||
beforeEach(() => {
|
||||
// SharedTripPage does NOT require authentication — do NOT seed auth store
|
||||
resetAllStores();
|
||||
@@ -818,10 +824,7 @@ describe('SharedTripPage', () => {
|
||||
const orsay = { id: 202, name: 'Orsay', lat: 48.85, lng: 2.32, category: null };
|
||||
const notre = { id: 203, name: 'Notre-Dame', lat: 48.853, lng: 2.35, category: null };
|
||||
|
||||
// Every divIcon call the last render produced, as raw html strings.
|
||||
const iconHtml = () => (L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: any[]) => String(c[0].html));
|
||||
|
||||
beforeEach(() => (L.divIcon as unknown as ReturnType<typeof vi.fn>).mockClear());
|
||||
beforeEach(() => divIconMock().mockClear());
|
||||
|
||||
it('numbers the stops by order_index, not by payload order', async () => {
|
||||
await open('order-map-token', payload({
|
||||
@@ -966,15 +969,15 @@ describe('SharedTripPage', () => {
|
||||
// The payload nests the category on a day's assignments but sends it flat on the
|
||||
// trip-wide pool, so reading only the nested shape painted every marker indigo.
|
||||
it('reads the flat category_color the trip pool sends', async () => {
|
||||
(L.divIcon as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
divIconMock().mockClear();
|
||||
await open('flatcat-token', payload({
|
||||
days: [],
|
||||
places: [{ id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category_color: '#ff8800', category_icon: 'landmark' }],
|
||||
assignments: {},
|
||||
}));
|
||||
|
||||
await waitFor(() => expect((L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(0));
|
||||
const html = (L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: any[]) => String(c[0].html));
|
||||
await waitFor(() => expect(iconHtml().length).toBeGreaterThan(0));
|
||||
const html = iconHtml();
|
||||
expect(html.some((h: string) => h.includes('#ff8800'))).toBe(true);
|
||||
expect(html.some((h: string) => h.includes('#6366f1'))).toBe(false);
|
||||
});
|
||||
@@ -1391,6 +1394,42 @@ describe('SharedTripPage', () => {
|
||||
// The booked night is not: it is already on the day as its own chip.
|
||||
expect(screen.queryByText('Unter den Linden 77')).toBeNull();
|
||||
});
|
||||
|
||||
it('numbers the map and draws the day line over the traveller\'s stops only, and keeps the hotel as an unnumbered pin', async () => {
|
||||
// Since the reseat the booked night heads its day, so counting it gave the hotel
|
||||
// badge 1, every real stop one more than the app shows, and a line setting off
|
||||
// from where the day ends.
|
||||
divIconMock().mockClear();
|
||||
const hotel = { ...place(602, 'Hotel Adlon', 'Unter den Linden 77'), lat: 52.52, lng: 13.38 };
|
||||
const rodin = { ...place(601, 'Musee Rodin', '77 Rue de Varenne'), lat: 48.86, lng: 2.32 };
|
||||
const orsay = { ...place(603, 'Musee d Orsay', '1 Rue de la Legion d Honneur'), lat: 48.86, lng: 2.33 };
|
||||
await open('stay-map-token', payload({
|
||||
days,
|
||||
assignments: {
|
||||
41: [
|
||||
{ id: 402, day_id: 41, order_index: 0, notes: null, accommodation_id: 7, place: hotel },
|
||||
{ id: 401, day_id: 41, order_index: 1, notes: null, accommodation_id: null, place: rodin },
|
||||
{ id: 403, day_id: 41, order_index: 2, notes: null, accommodation_id: null, place: orsay },
|
||||
],
|
||||
},
|
||||
accommodations: [{ id: 7, place_id: 602, start_day_id: 41, end_day_id: 41, place_name: 'Hotel Adlon' }],
|
||||
}));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
|
||||
await waitFor(() => expect(iconHtml().some((h: string) => h.includes('>1<'))).toBe(true));
|
||||
|
||||
// Rodin is 1 and Orsay is 2, as in the planner; nothing wears 3 and the hotel's
|
||||
// pin is still drawn, without a badge.
|
||||
const html = iconHtml();
|
||||
expect(html.filter((h: string) => h.includes('>1<'))).toHaveLength(1);
|
||||
expect(html.filter((h: string) => h.includes('>2<'))).toHaveLength(1);
|
||||
expect(html.some((h: string) => h.includes('>3<'))).toBe(false);
|
||||
expect(html.filter((h: string) => !h.includes('<span'))).toHaveLength(1);
|
||||
|
||||
// The day line joins the two stops and does not start at the hotel.
|
||||
const line = screen.getByTestId('route-line');
|
||||
expect(JSON.parse(line.getAttribute('data-positions') ?? '[]')).toEqual([[48.86, 2.32], [48.86, 2.33]]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #2320: the richer read-only detail ─────────────────────────────────
|
||||
|
||||
@@ -167,22 +167,33 @@ export default function SharedTripPage() {
|
||||
// trip-wide pool has none (it arrives by created_at). The index runs over the full
|
||||
// sorted assignment list, like the planner does, so a stop without coordinates still
|
||||
// consumes a number and the app and the share link agree on what "3" means.
|
||||
//
|
||||
// The stop a booked night wrote onto its check-in day heads that day since the
|
||||
// reseat, and the planner's numbers leave it out (the day list below does too). So
|
||||
// the numbers and the day line are counted over the traveller's own stops, or the
|
||||
// hotel wore badge 1, every real stop read one higher than in the app, and the line
|
||||
// set off from where the day ends. Its pin stays on the day, unnumbered, the way the
|
||||
// planner keeps the hotel on the map.
|
||||
const dayAssignments = selectedDay
|
||||
? [...(assignments[String(selectedDay)] || [])].sort((a: any, b: any) => a.order_index - b.order_index)
|
||||
: [];
|
||||
const dayStops = dayAssignments.filter(a => a.accommodation_id == null);
|
||||
const dayOrderMap: Record<number, number[]> = {};
|
||||
dayAssignments.forEach((a: any, i: number) => {
|
||||
dayStops.forEach((a, i) => {
|
||||
if (!a.place?.id) return;
|
||||
(dayOrderMap[a.place.id] ||= []).push(i + 1);
|
||||
});
|
||||
const dayPlaces: any[] = [];
|
||||
const seenPlaceIds = new Set<number>();
|
||||
for (const a of dayAssignments as any[]) {
|
||||
const p = a.place;
|
||||
if (!p?.lat || !p?.lng || seenPlaceIds.has(p.id)) continue;
|
||||
seenPlaceIds.add(p.id);
|
||||
dayPlaces.push(p);
|
||||
}
|
||||
// The places these assignments sit on, in their order, each drawn once.
|
||||
const locatedPlaces = (list: typeof dayAssignments) => {
|
||||
const seen = new Set<number>();
|
||||
return list.map(a => a.place).filter(p => {
|
||||
if (!p?.lat || !p?.lng || seen.has(p.id)) return false;
|
||||
seen.add(p.id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
const dayPlaces = locatedPlaces(dayAssignments);
|
||||
const dayLine = locatedPlaces(dayStops);
|
||||
const mapPlaces = selectedDay ? dayPlaces : (places || []).filter((p: any) => p?.lat && p?.lng);
|
||||
|
||||
// Open framed on the trip's places instead of on Paris. MapContainer only reads center/zoom
|
||||
@@ -451,9 +462,9 @@ export default function SharedTripPage() {
|
||||
/>
|
||||
)}
|
||||
<FitBoundsToPlaces places={mapPlaces} framedOnMount={framed !== null} />
|
||||
{selectedDay && mapPlaces.length > 1 && (
|
||||
{selectedDay && dayLine.length > 1 && (
|
||||
<Polyline
|
||||
positions={mapPlaces.map((p: any) => [p.lat, p.lng])}
|
||||
positions={dayLine.map(p => [p.lat, p.lng])}
|
||||
// Dashed and straight on purpose: it shows the order of the day's stops,
|
||||
// not the roads between them. A real route would mean sending the
|
||||
// itinerary to a third party for every anonymous visitor of a shared
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import { render, screen, waitFor, act, fireEvent } from '../../tests/helpers/render';
|
||||
import { Routes, Route } from 'react-router';
|
||||
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment } from '../../tests/helpers/factories';
|
||||
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment, buildReservation } from '../../tests/helpers/factories';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTripStore } from '../store/tripStore';
|
||||
import { usePluginStore } from '../store/pluginStore';
|
||||
@@ -173,6 +173,34 @@ vi.mock('../components/Trips/TripMembersModal', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// The road-trip rail and the booking dialog it brings along (#2428). Both capture their
|
||||
// props so a case can drive the rail's handlers and read what the page did with them.
|
||||
type RoadtripSidebarStubProps = { onOpenBooking?: (reservationId: number) => void; canEditBookings?: boolean };
|
||||
const capturedRoadtripSidebarProps: { current: RoadtripSidebarStubProps } = { current: {} };
|
||||
vi.mock('../components/Roadtrip/RoadtripSidebar', () => ({
|
||||
default: (props: RoadtripSidebarStubProps) => {
|
||||
capturedRoadtripSidebarProps.current = props;
|
||||
return React.createElement('div', { 'data-testid': 'roadtrip-sidebar' });
|
||||
},
|
||||
}));
|
||||
|
||||
type TransportDetailStubProps = { transportDetail?: { id: number } | null };
|
||||
const capturedTransportDetailModalProps: { current: TransportDetailStubProps } = { current: {} };
|
||||
vi.mock('../components/Planner/DayPlanSidebarTransportDetailModal', () => ({
|
||||
DayPlanSidebarTransportDetailModal: (props: TransportDetailStubProps) => {
|
||||
capturedTransportDetailModalProps.current = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/Roadtrip/RoadtripCorridorPanel', () => ({
|
||||
default: () => React.createElement('div', { 'data-testid': 'roadtrip-corridor-panel' }),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Roadtrip/RoadtripLimitsCard', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
// Configurable usePlaceSelection mock — lets tests set a specific selected place
|
||||
const mockPlaceSelectionState: { selectedPlaceId: number | null; selectedAssignmentId: number | null } = {
|
||||
selectedPlaceId: null,
|
||||
@@ -253,6 +281,8 @@ beforeEach(() => {
|
||||
capturedTripMembersModalProps.current = {};
|
||||
capturedFileManagerProps.current = {};
|
||||
capturedPlaceInspectorProps.current = {};
|
||||
capturedRoadtripSidebarProps.current = {};
|
||||
capturedTransportDetailModalProps.current = {};
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
||||
});
|
||||
|
||||
@@ -1709,4 +1739,60 @@ describe('TripPlannerPage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-052: Road trip mode opens a booking the way the day plan does (#2428)', () => {
|
||||
// The rail is mounted only with the addon on and the mode on for this trip; the
|
||||
// road-trip hooks then read their own endpoints, answered empty here.
|
||||
const enterRoadtrip = () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [{ id: 'roadtrip', type: 'roadtrip' }] })),
|
||||
http.get('/api/trips/42/roadtrip/vias', () => HttpResponse.json({ vias: [], tracks: [] })),
|
||||
http.get('/api/trips/42/roadtrip/preferences', () => HttpResponse.json({ tripId: 42, preferences: {} })),
|
||||
);
|
||||
sessionStorage.setItem('trip-roadtrip-42', '1');
|
||||
};
|
||||
|
||||
it('a flight opens the transport detail view the rail brings along, a table opens its editor', async () => {
|
||||
enterRoadtrip();
|
||||
vi.useFakeTimers();
|
||||
seedTripStore({ id: 42 });
|
||||
const flight = buildReservation({ id: 70, trip_id: 42, type: 'flight', title: 'LH 2020' });
|
||||
const table = buildReservation({ id: 11, trip_id: 42, type: 'restaurant', title: 'Tisch Bullerei' });
|
||||
seedStore(useTripStore, { reservations: [flight, table] });
|
||||
|
||||
renderPlannerPage(42);
|
||||
|
||||
act(() => { vi.runAllTimers(); });
|
||||
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('roadtrip-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('day-plan-sidebar')).not.toBeInTheDocument();
|
||||
|
||||
// Without this the rail cannot tell a chip that opens from one that does not, and
|
||||
// every table and ticket becomes a button that no-ops.
|
||||
expect(capturedRoadtripSidebarProps.current.canEditBookings).toBe(true);
|
||||
|
||||
// A terminal row, a ride pill and a map endpoint all set the booking to show, and
|
||||
// under Days the day panel owns the dialog that shows it. Here it has to be the
|
||||
// rail's own copy, or nothing shows.
|
||||
act(() => { capturedRoadtripSidebarProps.current.onOpenBooking?.(70); });
|
||||
await waitFor(() => {
|
||||
expect(capturedTransportDetailModalProps.current.transportDetail).toMatchObject({ id: 70 });
|
||||
});
|
||||
expect(capturedReservationModalProps.current.isOpen).toBe(false);
|
||||
|
||||
act(() => { capturedRoadtripSidebarProps.current.onOpenBooking?.(11); });
|
||||
await waitFor(() => {
|
||||
expect(capturedReservationModalProps.current.isOpen).toBe(true);
|
||||
});
|
||||
expect(capturedReservationModalProps.current.reservation).toMatchObject({ id: 11 });
|
||||
|
||||
// A booking the trip does not hold opens nothing.
|
||||
act(() => { capturedRoadtripSidebarProps.current.onOpenBooking?.(999); });
|
||||
expect(capturedTransportDetailModalProps.current.transportDetail).toMatchObject({ id: 70 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,16 @@ 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)
|
||||
}}
|
||||
canEditBookings={can('reservation_edit', trip)}
|
||||
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
|
||||
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
|
||||
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
|
||||
@@ -612,6 +625,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,30 @@ 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-030f: with a key but Amap or OpenStreetMap picked, the Google-only row says the switch is idle', () => {
|
||||
// A key alone does not put Google behind the search; the server ignores the
|
||||
// switch under another provider, and the row must not promise otherwise.
|
||||
renderTab({ placesGoogleOnly: true, hasMapsKey: true, placesProvider: 'openstreetmap' });
|
||||
const hint = toggleFor('Search with Google only').closest<HTMLElement>('.flex.items-center.justify-between')!.querySelectorAll('p')[1];
|
||||
expect(hint.textContent).not.toMatch(/Every search and every suggestion goes to Google Places/);
|
||||
expect(hint.textContent).not.toMatch(/Needs a Google Maps API key/);
|
||||
expect(hint.textContent).toMatch(/provider/i);
|
||||
});
|
||||
|
||||
it('FE-ADMSET-031: the API keys card leads with the TREK index and no weather panel', () => {
|
||||
renderTab();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import CustomSelect from '../../components/shared/CustomSelect'
|
||||
import GoogleOptions from './GoogleOptions'
|
||||
import ProviderBlock from './ProviderBlock'
|
||||
import TrekApiCard from './TrekApiCard'
|
||||
import { placesGoogleOnlyHint } from '../../utils/placeSource'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { useAdmin } from './useAdmin'
|
||||
|
||||
@@ -25,6 +26,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 +450,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 +522,22 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The one row here that is about where a search goes rather than
|
||||
what the key may be spent on. Without a key, or with Amap or
|
||||
OpenStreetMap holding the slot, it is a promise the search
|
||||
cannot keep, and the subtitle says so. */}
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-content-secondary">{t('admin.placesGoogleOnly.title')}</p>
|
||||
<p className="text-xs text-content-faint mt-0.5">{t(placesGoogleOnlyHint(hasMapsKey, placesProvider))}</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
on={placesGoogleOnly}
|
||||
label={t('admin.placesGoogleOnly.title')}
|
||||
onToggle={handleTogglePlacesGoogleOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GoogleOptions>
|
||||
</ProviderBlock>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ChevronRight } from 'lucide-react'
|
||||
interface GoogleOptionsProps {
|
||||
/** "Google options" — the heading of the fold. */
|
||||
title: string
|
||||
/** Right-hand summary while folded, e.g. "3 of 4 on". */
|
||||
/** Right-hand summary while folded, e.g. "4 of 5 on". */
|
||||
summary: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
@@ -15,7 +15,7 @@ interface GoogleOptionsProps {
|
||||
* Only these switches fold away. The key fields above stay visible: a field you
|
||||
* have to go looking for is worse than a long page, and the keys are the reason
|
||||
* an admin opens this card at all. The switches are the opposite — set once,
|
||||
* then never touched, and there are four of them.
|
||||
* then never touched, and there are five of them.
|
||||
*
|
||||
* A native <details> rather than a state hook: keyboard operable and announced
|
||||
* to screen readers without writing any of that, and it survives a re-render
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMHOOK-001 to FE-ADMHOOK-049
|
||||
// FE-ADMHOOK-001 to FE-ADMHOOK-051
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -276,6 +276,38 @@ describe('useAdmin', () => {
|
||||
expect(toastCalls.some(c => c.type === 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMHOOK-050: handleTogglePlacesGoogleOnly flips the switch and PUTs it', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/admin/places-google-only', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ enabled: true });
|
||||
})
|
||||
);
|
||||
const { result } = await mountAdmin();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTogglePlacesGoogleOnly();
|
||||
});
|
||||
|
||||
expect(body).toEqual({ enabled: true });
|
||||
expect(result.current.placesGoogleOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMHOOK-051: a rejected Google-only update rolls the switch back and toasts', async () => {
|
||||
server.use(
|
||||
http.put('/api/admin/places-google-only', () => HttpResponse.json({ error: 'locked' }, { status: 500 }))
|
||||
);
|
||||
const { result } = await mountAdmin();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTogglePlacesGoogleOnly();
|
||||
});
|
||||
|
||||
expect(result.current.placesGoogleOnly).toBe(false);
|
||||
expect(toastCalls).toContainEqual({ type: 'error', message: 'locked' });
|
||||
});
|
||||
|
||||
it('FE-ADMHOOK-016: handleSaveWebauthn trims and re-reads the app config', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
|
||||
@@ -105,6 +105,11 @@ 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(() => {}) }, [])
|
||||
|
||||
// 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.
|
||||
@@ -192,6 +197,9 @@ export function useAdmin() {
|
||||
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
||||
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled, logout } = useAuthStore()
|
||||
// The store's copy is what the search screens gate the "search Google
|
||||
// instead" line on, so a saved choice lands there the way a saved key does.
|
||||
const setStorePlacesProvider = useAuthStore(s => s.setPlacesProvider)
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -354,12 +362,24 @@ export function useAdmin() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePlacesGoogleOnly = async () => {
|
||||
const next = !placesGoogleOnly
|
||||
setPlacesGoogleOnlyState(next)
|
||||
try {
|
||||
await adminApi.updatePlacesGoogleOnly(next)
|
||||
} catch (err: unknown) {
|
||||
setPlacesGoogleOnlyState(!next)
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePlacesProvider = async (value: string) => {
|
||||
const previous = placesProvider
|
||||
setPlacesProvider(value)
|
||||
setSavingPlacesProvider(true)
|
||||
try {
|
||||
await authApi.updateAppSettings({ places_provider: value })
|
||||
setStorePlacesProvider(value)
|
||||
toast.success(t('admin.placesProvider.saved'))
|
||||
} catch (err: unknown) {
|
||||
setPlacesProvider(previous)
|
||||
@@ -481,6 +501,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,9 @@ export interface PublicPhoto {
|
||||
caption?: string | null
|
||||
// 'image' (default) or 'video' (#823)
|
||||
media_type?: string | null
|
||||
/** Null on a local clip whose poster frame could not be grabbed; the page
|
||||
* draws a marked tile instead of an <img> then (#2341). */
|
||||
thumbnail_path?: string | null
|
||||
duration_ms?: number | null
|
||||
}
|
||||
|
||||
@@ -44,6 +47,9 @@ export interface PublicGalleryPhoto {
|
||||
caption?: string | null
|
||||
// 'image' (default) or 'video' (#823)
|
||||
media_type?: string | null
|
||||
/** Null on a local clip whose poster frame could not be grabbed; the gallery
|
||||
* draws a marked tile instead of an <img> then (#2341). */
|
||||
thumbnail_path?: string | null
|
||||
duration_ms?: number | null
|
||||
/** Where and when the picture was taken (#1614). Absent for most photos, and the
|
||||
* coordinates are withheld entirely unless the owner shared the map. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { JourneyMapHandle } from '../../components/Journey/JourneyMap'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { DAY_COLORS } from '../../components/Journey/dayColors'
|
||||
import { posterlessVideo } from '../journeyDetail/JourneyDetailPage.helpers'
|
||||
import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublicModel'
|
||||
|
||||
/**
|
||||
@@ -86,10 +87,14 @@ export function useJourneyPublic() {
|
||||
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
|
||||
dayLabel,
|
||||
// Through the share token, like every other picture on this page: the
|
||||
// reader has no session, so `/api/photos/:id` would answer 401.
|
||||
// reader has no session, so `/api/photos/:id` would answer 401. The
|
||||
// poster rather than the file, as the owner's own map card does, since
|
||||
// /original is the whole clip for a video; a clip that came up without
|
||||
// a poster is dropped instead of holding a blank slot (#2341).
|
||||
photoUrls: (e.photos ?? [])
|
||||
.filter(p => !posterlessVideo(p))
|
||||
.slice(0, 3)
|
||||
.map((p: { photo_id: number }) => `/api/public/journey/${token}/photos/${p.photo_id}/original`),
|
||||
.map(p => `/api/public/journey/${token}/photos/${p.photo_id}/thumbnail`),
|
||||
}
|
||||
})
|
||||
}, [mapEntries, sortedDates, token])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-108
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-112
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
@@ -2422,3 +2422,69 @@ describe('useTripPlanner road trip: a hit handed to the full form', () => {
|
||||
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 6, 900, null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner road trip: a ride on the drive (#2428)', () => {
|
||||
/**
|
||||
* One card: Hamburg, the airport it flies from, the airport it lands at, Munich. The
|
||||
* ride draws no line of its own, so the card's geometry jumps from one terminal to the
|
||||
* other and a click under the flight path projects onto that jump. The terminals carry
|
||||
* the index of the stop after them, as `seatCarrierStops` seats them: they stand in
|
||||
* for no stored stop of their own.
|
||||
*/
|
||||
const terminal = (role: 'departure' | 'arrival') => ({
|
||||
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
|
||||
placeId: -70,
|
||||
ownerIndex: 1,
|
||||
lat: role === 'departure' ? 53.63 : 48.35,
|
||||
lng: role === 'departure' ? 9.99 : 11.78,
|
||||
carrier: { reservationId: 70, type: 'flight', role, title: 'LH 2020', code: role === 'departure' ? 'HAM' : 'MUC', at: null },
|
||||
})
|
||||
const flownDay = () => {
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
rt.routes.days = [{
|
||||
dayId: 5,
|
||||
dayNumber: 1,
|
||||
stops: [{ assignmentId: 1, placeId: 10, lat: 53.55, lng: 9.99 }, terminal('departure'), terminal('arrival'), { assignmentId: 2, placeId: 20, ownerIndex: 1, lat: 48.13, lng: 11.58 }],
|
||||
geometry: [[53.55, 9.99], [53.63, 9.99], [48.35, 11.78], [48.13, 11.58]],
|
||||
}]
|
||||
}
|
||||
|
||||
it('FE-TP-ROAD-109: a via is filed on the road into the departure terminal, never on the ride or the road out of the arrival', async () => {
|
||||
flownDay()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.addRoadtripVia(53.59, 9.99) })
|
||||
expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.59, 9.99)
|
||||
|
||||
rt.vias.add.mockClear()
|
||||
// Under the flight path, and on the road out of the airport it lands at: a via there
|
||||
// would be stored at the next stop's position and bend that stop's road instead.
|
||||
await act(async () => { await result.current.addRoadtripVia(50.99, 10.885) })
|
||||
await act(async () => { await result.current.addRoadtripVia(48.24, 11.68) })
|
||||
expect(rt.vias.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-110: a via dragged onto the road out of the arrival moves without an anchor', async () => {
|
||||
flownDay()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.moveRoadtripVia(5, 3, 48.24, 11.68) })
|
||||
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 48.24, 11.68, undefined)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-111: nothing is stopped at on a flight; behind the arrival the road goes on', async () => {
|
||||
flownDay()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
expect(result.current.manualStopTargetFor(50.99, 10.885)).toBeNull()
|
||||
expect(result.current.manualStopTargetFor(48.24, 11.68)).toMatchObject({ dayId: 5 })
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-112: the ride is drawn on the map as its booking, on top of whatever the reader switched on', async () => {
|
||||
flownDay()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
expect(result.current.roadtripConnections).toContain(70)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2092,6 +2092,21 @@ describe('useTripPlanner — misc state', () => {
|
||||
expect(result.current.prefillCoords).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-120: without place_edit rights the place editor and the delete dialog stay shut (#2446)', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 2, role: 'user' }) })
|
||||
usePermissionsStore.setState({ permissions: { place_edit: 'admin' } })
|
||||
const place = buildPlace({ id: 1, lat: 1, lng: 2 })
|
||||
seedTrip({ places: [place], assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place })] } })
|
||||
|
||||
const { result } = await renderPlanner()
|
||||
act(() => { result.current.openPlaceEditor(place, 10) })
|
||||
act(() => { result.current.handleDeletePlace(1) })
|
||||
|
||||
expect(result.current.showPlaceForm).toBe(false)
|
||||
expect(result.current.editingPlace).toBeNull()
|
||||
expect(result.current.deletePlaceId).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-102: a member roster refresh replaces the cached list', async () => {
|
||||
seedTrip()
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
@@ -14,6 +14,7 @@ import { resolvePluginIcon } from '../../components/shared/PluginIcon'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
|
||||
import { getDayOrder } from '../../utils/dayOrder'
|
||||
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
|
||||
import { isOvernightCategory } from '../../components/Roadtrip/stopKinds'
|
||||
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
|
||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||
@@ -30,6 +31,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 +78,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 +96,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).
|
||||
@@ -213,7 +216,6 @@ export function useTripPlanner() {
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
|
||||
|
||||
const tripPagePlugins = allPlugins.filter(p => p.type === 'trip-page')
|
||||
const tripPluginIds = tripPagePlugins.map(p => p.id).join(',')
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -2074,6 +2095,7 @@ export function useTripPlanner() {
|
||||
// place's lone assignment to hydrate & persist its times; with 0 or 2+
|
||||
// assignments the time is ambiguous and the modal hides the fields (#1247).
|
||||
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') {
|
||||
const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)
|
||||
const entry = Object.entries(assignments).find(([, visits]) => visits.some(visit => visit.id === visitId))
|
||||
@@ -2098,7 +2120,7 @@ export function useTripPlanner() {
|
||||
setPlaceFormDayId(null)
|
||||
setServiceStopForm(false)
|
||||
setShowPlaceForm(true)
|
||||
}, [assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
|
||||
}, [can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
|
||||
|
||||
/**
|
||||
* How long the drive stands here, for every stop alike.
|
||||
@@ -2113,8 +2135,9 @@ export function useTripPlanner() {
|
||||
}, [])
|
||||
|
||||
const handleDeletePlace = useCallback((placeId) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
setDeletePlaceId(placeId)
|
||||
}, [])
|
||||
}, [can, trip])
|
||||
|
||||
const confirmDeletePlace = useCallback(async () => {
|
||||
if (!deletePlaceId) return
|
||||
@@ -2592,7 +2615,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 +2665,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,
|
||||
|
||||
@@ -52,6 +52,10 @@ interface AuthState {
|
||||
* into one "has a search key": which of the two is missing decides what the
|
||||
* admin has to go and do. */
|
||||
hasAmapKey: boolean
|
||||
/** The admin's places provider choice, as app-config normalises it: 'auto',
|
||||
* 'google', 'amap' or 'openstreetmap'. Read with hasMapsKey to tell whether
|
||||
* a search can reach Google at all (utils/placeSource googleHoldsSlot). */
|
||||
placesProvider: string
|
||||
serverTimezone: string
|
||||
/** Server policy: all users must enable MFA */
|
||||
appRequireMfa: boolean
|
||||
@@ -81,6 +85,7 @@ interface AuthState {
|
||||
setAppVersion: (val: string) => void
|
||||
setHasMapsKey: (val: boolean) => void
|
||||
setHasAmapKey: (val: boolean) => void
|
||||
setPlacesProvider: (val: string) => void
|
||||
setServerTimezone: (tz: string) => void
|
||||
setAppRequireMfa: (val: boolean) => void
|
||||
setTripRemindersEnabled: (val: boolean) => void
|
||||
@@ -132,6 +137,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
appVersion: '',
|
||||
hasMapsKey: false,
|
||||
hasAmapKey: false,
|
||||
placesProvider: 'auto',
|
||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
appRequireMfa: false,
|
||||
tripRemindersEnabled: false,
|
||||
@@ -402,6 +408,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
setAppVersion: (val: string) => set({ appVersion: val }),
|
||||
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
||||
setHasAmapKey: (val: boolean) => set({ hasAmapKey: val }),
|
||||
setPlacesProvider: (val: string) => set({ placesProvider: val }),
|
||||
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
||||
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
|
||||
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
|
||||
|
||||
@@ -194,6 +194,20 @@ async function _writeDayToDb(dayId: number, state: TripStoreState): Promise<void
|
||||
|
||||
// ── Zustand event reducer ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A day with `incoming` seated where the server put it, the rows around it renumbered
|
||||
* the way the local move reducer renumbers them. A booked night lands at the front of
|
||||
* its day and the rows behind it were moved up one there; appended with that index it
|
||||
* would sit tied with the old first row and be drawn second until the next reload. An
|
||||
* index past the end, or none at all, appends.
|
||||
*/
|
||||
function seatByOrder(day: Assignment[], incoming: Assignment): Assignment[] {
|
||||
const ordered = day.slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
const at = Math.max(0, Math.min(incoming.order_index ?? ordered.length, ordered.length))
|
||||
ordered.splice(at, 0, incoming)
|
||||
return ordered.map((a, i) => (a.order_index === i ? a : { ...a, order_index: i }))
|
||||
}
|
||||
|
||||
type StateApplier = (payload: Record<string, unknown>, state: TripStoreState) => Partial<TripStoreState>
|
||||
|
||||
/**
|
||||
@@ -252,13 +266,8 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
|
||||
}
|
||||
|
||||
// Genuinely new — including a legitimate second assignment of a place
|
||||
// already on this day (no temp version to reconcile). Append.
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: [...existing, incoming],
|
||||
}
|
||||
}
|
||||
// already on this day (no temp version to reconcile).
|
||||
return { assignments: { ...state.assignments, [dayKey]: seatByOrder(existing, incoming) } }
|
||||
},
|
||||
'assignment:updated': (payload, state) => {
|
||||
const dayKey = String((payload.assignment as Assignment).day_id)
|
||||
@@ -284,11 +293,14 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
|
||||
const oldKey = String(payload.oldDayId)
|
||||
const newKey = String(payload.newDayId)
|
||||
const movedAssignment = payload.assignment as Assignment
|
||||
// The target day is read after the row has left it, so a night re-seated on its
|
||||
// own day (oldKey === newKey) is taken out first and put back where it now sits.
|
||||
const target = (state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
|
||||
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
|
||||
[newKey]: seatByOrder(target, movedAssignment),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,29 @@
|
||||
}
|
||||
.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: 1.12; letter-spacing: -0.045em; margin: 0;
|
||||
text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4);
|
||||
/* Two lines at most, a word longer than the card broken inside it: the rows above
|
||||
and below have a fixed budget and a third line would run into the badge row. */
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
|
||||
/* The clamp clips at the line boxes and Poppins paints past them (ascender 1.05em,
|
||||
descender 0.35em): at 0.9 every y, g and p on the last line lost its tail and
|
||||
É and Å on the first their accent. The padding makes room for the ink and the
|
||||
margin takes it back, so the title sits where it did. At 1.12 a clipped third
|
||||
line keeps its plain ascenders under that padding; an accented capital on it
|
||||
still leaves a sliver, as it does on dev today, and only a much looser title
|
||||
would hide that. */
|
||||
padding-block: .1em; margin-block: -.1em;
|
||||
}
|
||||
|
||||
/* ----------------- boarding pass ----------------- */
|
||||
/* The pass is split into an outer wrapper (left notch) and an inner glass panel
|
||||
@@ -416,6 +435,11 @@
|
||||
.trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; }
|
||||
.trek-dash .trips.list-view .trip-card { display: grid; grid-template-columns: 520px 1fr; gap: 0; height: auto; }
|
||||
.trek-dash .trips.list-view .trip-cover { border-radius: var(--r-lg) 0 0 var(--r-lg); height: 100px; aspect-ratio: unset; }
|
||||
/* The row's cover is 100px and the title hangs from its bottom edge, so every
|
||||
line it gains grows upwards: at 26px a second one starts above the status
|
||||
badge at top 16px and runs through the pill. The row has 484px of text to
|
||||
itself, one line is the whole title either way. */
|
||||
.trek-dash .trips.list-view .trip-name { -webkit-line-clamp: 1; }
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-end; padding: 16px 36px; gap: 28px; }
|
||||
/* Date rendered as a peer of the counts, set off by a vertical divider rather than
|
||||
floating alone at the far left. */
|
||||
@@ -470,7 +494,15 @@
|
||||
.trek-dash .trip-action-btn:hover { background: oklch(0.22 0.01 260 / .82); transform: scale(1.06); }
|
||||
.trek-dash .trip-action-btn svg { width: 17px; height: 17px; }
|
||||
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
|
||||
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
|
||||
.trek-dash .trip-name {
|
||||
font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.12; margin: 0;
|
||||
text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38);
|
||||
/* The cover is a fixed width and clipped, so the same clamp and ink room as
|
||||
.hero-title: one long word used to run off the card edge as a single line. */
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
|
||||
padding-block: .1em; margin-block: -.1em;
|
||||
}
|
||||
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
|
||||
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
|
||||
.trek-dash .trip-body { padding: 18px 20px 20px; }
|
||||
@@ -719,10 +751,9 @@
|
||||
.trek-dash .trips.list-view .trip-card { grid-template-columns: 1fr; min-height: 0; }
|
||||
.trek-dash .trips.list-view .trip-cover { height: 110px; aspect-ratio: unset; border-radius: 0; }
|
||||
.trek-dash .trips.list-view .trip-cover-content { left: 16px; right: 16px; bottom: 11px; }
|
||||
.trek-dash .trips.list-view .trip-name {
|
||||
font-size: 18px; overflow: hidden; text-overflow: ellipsis;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
}
|
||||
/* Taller banner (110px), smaller type and the title 11px off the bottom: two
|
||||
lines end below the badge here, so the phone row keeps them. */
|
||||
.trek-dash .trips.list-view .trip-name { font-size: 18px; -webkit-line-clamp: 2; }
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: center; padding: 10px 16px; }
|
||||
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: center; font-size: 12.5px; }
|
||||
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 12.5px; }
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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 within = (source: string, selector: string): string => {
|
||||
const at = source.indexOf(selector)
|
||||
expect(at, `${selector} missing from dashboard.css`).toBeGreaterThan(-1)
|
||||
return source.slice(at, source.indexOf('}', at) + 1)
|
||||
}
|
||||
const block = (selector: string): string => within(css, selector)
|
||||
const phone = (): string => {
|
||||
const at = css.indexOf('@media (max-width: 720px)')
|
||||
expect(at, 'the phone block').toBeGreaterThan(-1)
|
||||
return css.slice(at, css.indexOf('\n}', at))
|
||||
}
|
||||
// The leading number of a declaration, undefined when the rule does not set it.
|
||||
const number = (rule: string, prop: string): number | undefined => {
|
||||
const found = new RegExp(`(?:^|[;{\\n])\\s*${prop}:\\s*(-?[\\d.]+)`).exec(rule)
|
||||
return found ? Number(found[1]) : undefined
|
||||
}
|
||||
const value = (rule: string, prop: string): number => {
|
||||
const found = number(rule, prop)
|
||||
expect(found, `${prop} missing from ${rule.slice(0, 48)}`).toBeDefined()
|
||||
return found!
|
||||
}
|
||||
|
||||
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/)
|
||||
})
|
||||
|
||||
it('FE-DASH-CSS-002: the clamp clips at the line boxes, so they leave room for the ink', () => {
|
||||
// Poppins paints past its line box (ascender 1.05em, descender 0.35em). With
|
||||
// the clamp's overflow: hidden at line-height 0.9, every y, g and p on the
|
||||
// last line lost its tail and É and Å on the first their accent. The room is
|
||||
// padding the margin takes back, and the line-height has to keep a clipped
|
||||
// third line's plain ascenders below that padding, which 1.12 just does (an
|
||||
// accented capital on that third line still leaves a sliver, as at 0.9).
|
||||
for (const selector of ['.trek-dash .hero-title {', '.trek-dash .trip-name {']) {
|
||||
const rule = block(selector)
|
||||
expect(rule, selector).toMatch(/overflow:\s*hidden/)
|
||||
expect(value(rule, 'line-height'), selector).toBeGreaterThanOrEqual(1.12)
|
||||
const room = /padding-block:\s*0?\.(\d+)em/.exec(rule)?.[1]
|
||||
expect(room, `${selector} pads no room for the ink`).toBeDefined()
|
||||
expect(rule, selector).toMatch(new RegExp(`margin-block:\\s*-0?\\.${room}em`))
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-DASH-CSS-003: a grid or list card breaks a one-word title inside the cover and stops at two lines', () => {
|
||||
// The same title that stretched the hero was cut at the cover edge on every
|
||||
// card: the cover content is absolutely positioned and the cover clips.
|
||||
const name = block('.trek-dash .trip-name {')
|
||||
expect(name).toMatch(/overflow-wrap:\s*anywhere/)
|
||||
expect(name).toMatch(/-webkit-line-clamp:\s*2/)
|
||||
})
|
||||
|
||||
it('FE-DASH-CSS-004: a list row uses every title line that fits above the status badge', () => {
|
||||
// The list cover is a fixed band: the badge is pinned near its top and the
|
||||
// title hangs from its bottom edge, so each line the title gains grows upwards
|
||||
// into the badge. At 26px over a 100px cover the two-line clamp put the first
|
||||
// line at y 23 and ran it through the pill; the phone banner is taller and its
|
||||
// type smaller, so there two fit. jsdom lays nothing out, so the row is
|
||||
// measured from the rules the browser would apply.
|
||||
const inherited = 1.5 // html's line-height; nothing in dashboard.css resets it
|
||||
const badge = block('.trek-dash .trip-status {')
|
||||
const badgeBottom =
|
||||
value(badge, 'top') + 2 * value(badge, 'border') + 2 * value(badge, 'padding') + value(badge, 'font-size') * inherited
|
||||
const name = block('.trek-dash .trip-name {')
|
||||
// What the row ends up with: its own override where it has one, the shared
|
||||
// .trip-name rule otherwise.
|
||||
const shell = (rule: string, prop: string): number => number(rule, prop) ?? value(name, prop)
|
||||
|
||||
const rows = [
|
||||
{
|
||||
label: 'desktop',
|
||||
cover: block('.trek-dash .trips.list-view .trip-cover {'),
|
||||
content: block('.trek-dash .trip-cover-content {'),
|
||||
row: block('.trek-dash .trips.list-view .trip-name {')
|
||||
},
|
||||
{
|
||||
label: 'phone',
|
||||
cover: within(phone(), '.trek-dash .trips.list-view .trip-cover {'),
|
||||
content: within(phone(), '.trek-dash .trips.list-view .trip-cover-content {'),
|
||||
row: within(phone(), '.trek-dash .trips.list-view .trip-name {')
|
||||
}
|
||||
]
|
||||
|
||||
for (const { label, cover, content, row } of rows) {
|
||||
const fontSize = shell(row, 'font-size')
|
||||
const line = fontSize * shell(row, 'line-height')
|
||||
// The clamp pads .1em for the ink and takes it back with a negative margin,
|
||||
// so the box keeps its height but paints that much higher.
|
||||
const ceiling = value(cover, 'height') - value(content, 'bottom') - fontSize * shell(row, 'padding-block')
|
||||
const clamp = shell(row, '-webkit-line-clamp')
|
||||
expect(ceiling - clamp * line, `${label} list title over the badge`).toBeGreaterThanOrEqual(badgeBottom)
|
||||
expect(clamp, `${label} list title leaves a line unused`).toBe(Math.floor((ceiling - badgeBottom) / line))
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user