Compare commits

...

14 Commits

Author SHA1 Message Date
github-actions[bot] ed8acf4c4b chore: bump version to 4.3.1 [skip ci] 2026-09-22 12:17:29 +00:00
Maurice a280517ff3 Merge pull request #2450 from liketrek/audit/431-fixes
fix(release): the audit round over v4.3.1
2026-09-22 13:30:38 +02:00
Maurice 2dd77aaac1 docs(wiki): the pages the 4.3.1 changes left behind
The check-in is an anchor now, not an earliest arrival, and a booked night
leads its check-in day, which the road trip and accommodation pages said
otherwise. Upgrading gains a 4.3.1 section for the reseat migration and for
what better-sqlite3 13 asks of a source install. The collaboration page knows
that links are chips and editable, the audit log knows the new admin action
and the SSO register row, and the MCP pages know the provider parameter and
the Google-only switch.
2026-09-22 13:30:12 +02:00
Maurice 85b5756e89 fix(client): the audit round over the 4.3.1 release
Ride minutes and late marks follow an edited timetable instead of the routing
that fetched them; a landing day's join band reads as the ride it is rather
than a nine hour drive of no length; the refuel offer is there on the road
out of a terminal or a pick-up desk; the phone's stage map draws the ride;
the share page numbers its stops the way the planner does; the PDF lists a
day the way the day plan lists it, from both shells; a poster-less clip draws
its play badge everywhere, not only in the editor; the hero title has room
for its descenders and the list card keeps its badge clear; the release
notice keeps its close control in reach when it stacks; a link chip's actions
are thumb-sized and its delete asks first; the Google retry line and the
Google-only switch appear only where Google answers.
2026-09-22 13:30:12 +02:00
Maurice cb85ba915c fix(roadtrip): one ride-gap rule for both corridor searches, and two seam edges
The corridor search in the browser cut its tiles around a ride, the MCP tool
did not: it tiled and measured along the straight line between two terminals,
so an assistant searching a flight day got boxes and hits under the flight
path. The gap logic moves into shared/roadtrip/corridor.ts and both sides use
it. A departure less than the check-in allowance after midnight no longer
pins on the previous evening, and a hire car whose pick-up desk has no
position is not seamed at its return desk.
2026-09-22 13:30:12 +02:00
Maurice 81292d4d2b fix(server): the booked night settles in one pass, and the sibling paths seat it too
The audit over the 4.3.1 release found the seating rule reading a night by one
value and its neighbours by another, so two nights on one day never agreed:
the migration reported moves that changed nothing, flipped the pair on the
next run and dropped the road drawn between them, and in the service every
notes edit swapped them. One rule now lives in accommodations/night-seat.ts,
measures every night by its own check-in, breaks a tie by the booking made
first, and carries the vias; the service, the reseat migration and a trip
date change all seat through it, the last of which used to append the night
at the end of its day again. An account created by a first SSO login writes
the user.register row the password path writes. Tests cover the equal and
absent check-ins, the date change, the booking form path and a day that is
nothing but its night.
2026-09-22 13:30:12 +02:00
Maurice 757967bf18 fix(map): the location button follows the viewport instead of the mount
Both renderers read window.innerWidth once while rendering, so the button
that turns GPS on was decided by the width the map happened to see when it
mounted: a phone turned sideways crosses the 768px cut and kept whatever it
had, and turning back did not bring it home either. Both now use the same
useIsPhone the shell switches on. The test stub for matchMedia answers width
queries from window.innerWidth and re-evaluates on a resize, so a breakpoint
can be driven from a test the way the browser drives it.
2026-09-22 12:57:04 +02:00
Maurice b0c19bfac1 fix(settings): the client registration form says what the server refused
Registering an OAuth client by hand and getting only "Failed to register
OAuth client" left nothing to act on: the server names the rule it broke (a
scope, the ten-client cap, a redirect URI it will not take) and the toast
swallowed it in a bare catch. Both shells show the server's message now and
keep the generic text for a failure that carries none.
2026-09-22 12:12:41 +02:00
Maurice da32eb8a3c fix(mcp): answer the discovery documents under the server address too
A client handed https://host/mcp as the server address looks for the
well-known documents below that path instead of asking the origin, and every
one of those probes fell through to the web app: 200 with an HTML body, which
reads to the client as "this server has no OAuth" (Open WebUI reports it as an
unexpected mimetype). The three documents are served under /mcp/.well-known
now, anything else there answers a JSON 404, and the RFC 8414 path-inserted
form /.well-known/oauth-authorization-server/mcp serves the metadata instead
of 404. The wiki says which redirect URI belongs to the client and that a
plain-HTTP APP_URL leaves the discovery document pointing at localhost.
2026-09-22 12:12:41 +02:00
Maurice a44df6cf11 fix(planner): place edit and delete controls follow place_edit, not day_edit
With places set to "Trip owner" a member who may edit days still saw Edit and
Delete on a place in the day plan's context menu, the inspector's action row,
its inline rename and the phone timeline's edit circle, and every one of them
ended in the server's 403. The day plan gates the two entries on place_edit
(taking a place off the day stays a day right), the inspector drops its edit,
delete and update handlers without the right the way the places pool already
does, the phone timeline offers the pencil only with it, and openPlaceEditor
and handleDeletePlace guard like their siblings.

Closes #2446
2026-09-22 11:29:43 +02:00
Maurice e510ff25ab fix(mcp): refuse an argument a tool never declared instead of dropping it
The SDK wraps a raw shape in a plain z.object, which strips an undeclared
key and runs the handler as if nothing was wrong: an assistant that wrote
stopType for stop_type was told the place was updated while nothing had
changed. The registry registers every shape as z.strictObject, so the call
answers "Unrecognized key" and tools/list advertises additionalProperties
false. The nested objects a tool takes, authored inline or spread from a
shared schema (legs, stops, links, transit places), are strict at this
boundary for the same reason; the shared schemas themselves stay open for
REST. A whole Zod schema, the way a plugin's manifest-built tool arrives,
passes through as its author wrote it.
2026-09-22 11:06:13 +02:00
Maurice 34f3aeee5f fix(trips): a trip created without a currency takes the display currency
The service wrote `data.currency || 'EUR'`, and only the trip dialog ever
sends a currency, pre-filled from the display currency in Settings. MCP
create_trip and the plugin RPC send none, so every trip an assistant created
was a EUR trip whatever the person reads amounts in. The service now asks
SettingsService for the owner's display currency, admin default merged in,
and falls back to EUR only when that is left on "Trip currency". The REST
route inherits the fallback; the web client always sends the field, so it
sees no change.
2026-09-22 11:06:13 +02:00
Maurice bdd326163f fix(client): take the reported version as one anchored match before storing it
The dev branch gate on SonarCloud has been red since the 4.3.0 cycle on this
one line: the version from /api/config is written to localStorage, and the
taint engine did not read the replace() plus equality check as sanitisation
(S8475). The value is the match of ^[\w.+-]{1,64}$ now, the same characters
and length as before, in the shape the engine recognises.
2026-09-22 10:32:08 +02:00
Maurice b1baf3baa9 fix(mobile): reservation-detail widgets mount on the phone's booking cards
A plugin declaring the reservation-detail slot mounted at the foot of every
card in the desktop ReservationsPanel and nowhere else: the phone's transport
and booking cards never rendered the slot, so on a phone in portrait the
reservation was there and the widget was not, and turning the phone sideways
made it appear. ReservationPluginSlots mounts one PluginFrame per widget under
each card, scoped to that reservation with surface detail-slot, outside the
card's body button and gone in compact mode.

Closes #2440
2026-09-22 10:32:08 +02:00
216 changed files with 4378 additions and 966 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 4.3.0 version: 4.3.1
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "4.3.0" appVersion: "4.3.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@trek/client", "name": "@trek/client",
"version": "4.3.0", "version": "4.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+12 -10
View File
@@ -284,7 +284,7 @@ function RouteFallback() {
} }
export default function App() { 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 { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore() const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore() const { loadPlugins } = usePluginStore()
@@ -301,7 +301,7 @@ export default function App() {
loadUser() 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) setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode) setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true) if (config?.dev_mode) setDevMode(true)
@@ -320,14 +320,13 @@ export default function App() {
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
// A version is a short release tag and nothing else. It arrives over the // 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 // wire and is written to this device's storage, so only a value made of
// characters a tag may contain and kept only when nothing had to be // the characters a tag may contain is taken, as the match itself. Anything
// stripped. Anything else is ignored, which also keeps a malformed value // else is ignored, which also keeps a malformed value from being compared
// from being compared against the stored marker and starting an update on // against the stored marker and starting an update on every launch.
// every launch. const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
const reportedVersion = typeof config?.version === 'string' ? config.version : '' if (releaseTag) {
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64) const version = releaseTag[0]
if (version && version === reportedVersion) {
const storedVersion = localStorage.getItem('trek_app_version') const storedVersion = localStorage.getItem('trek_app_version')
// Record the version BEFORE acting on it. The old code wrote the marker // 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 // after the purge and outside its try, so a throwing setItem (private
@@ -360,6 +359,9 @@ export default function App() {
return 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(() => {}) }).catch(() => {})
}, []) }, [])
+4 -4
View File
@@ -1216,11 +1216,11 @@ export const mapsApi = {
* caller of this one function gets them without knowing they exist. Appended rather * 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, * 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. * 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 * `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 * instead" link under a list the index answered with the wrong place. The server
* server ignores it on an instance without a Google key. * ignores it unless Google holds the keyed slot: without a Google key, or with
* Amap or OpenStreetMap picked as the provider, the index answers as usual.
*/ */
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') => search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') =>
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => { withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
@@ -1,4 +1,4 @@
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008 // FE-COMP-LINKS-001 to FE-COMP-LINKS-013
vi.mock('../../api/websocket', () => ({ vi.mock('../../api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -10,7 +10,9 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(), 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 userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server'; import { server } from '../../../tests/helpers/msw/server';
@@ -160,6 +162,65 @@ describe('CollabLinks', () => {
expect(screen.getByText('ferries.example')).toBeInTheDocument(); 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 () => { 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. // collab_edit reserved for the owner, on somebody else's trip.
seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } }); seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } });
+16 -2
View File
@@ -6,6 +6,7 @@ import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import ConfirmDialog from '../shared/ConfirmDialog'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
@@ -111,6 +112,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
const [links, setLinks] = useState<CollabLink[]>([]) const [links, setLinks] = useState<CollabLink[]>([])
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CollabLink | null>(null) 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 // 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. // list after each keystroke and drop a link that was just added back out of it.
@@ -230,7 +232,9 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
{links.map(link => ( {links.map(link => (
<div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}> <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')}> <a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer" title={t('collab.links.open')}>
<LinkIcon url={link.url} /> {/* Keyed on the address: the icon remembers a favicon that failed, and
an edit keeps the chip's id, so a corrected address got the glyph. */}
<LinkIcon key={link.url} url={link.url} />
<span className="collab-link-chip__text"> <span className="collab-link-chip__text">
<span className="collab-link-chip__title">{link.title}</span> <span className="collab-link-chip__title">{link.title}</span>
<span className="collab-link-chip__host">{hostOf(link.url)}</span> <span className="collab-link-chip__host">{hostOf(link.url)}</span>
@@ -244,7 +248,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
<button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} title={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}> <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" /> <Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button> </button>
<button type="button" className="collab-link-chip__action" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}> <button type="button" className="collab-link-chip__action" onClick={() => setPendingDeleteId(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<Trash2 size={13} aria-hidden="true" /> <Trash2 size={13} aria-hidden="true" />
</button> </button>
</span> </span>
@@ -258,6 +262,16 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
{/* Create and edit modals */} {/* Create and edit modals */}
{showForm && <LinkModal onClose={() => setShowForm(false)} onSave={add} />} {showForm && <LinkModal onClose={() => setShowForm(false)} onSave={add} />}
{editing && <LinkModal key={editing.id} link={editing} onClose={() => setEditing(null)} onSave={data => edit(editing, data)} />} {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> </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 { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw' import { http, HttpResponse, delay } from 'msw'
@@ -1045,4 +1045,30 @@ describe('EntryEditor', () => {
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument() expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).not.toBeInTheDocument() expect(container.querySelector('svg.lucide-play')).not.toBeInTheDocument()
}) })
it('FE-JRN-EDITOR-058: the gallery picker gives a clip without a poster the same play badge (#2341)', async () => {
const user = userEvent.setup()
const clip = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = mountEditor(buildEntry(), { galleryPhotos: [clip] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
expect(container.querySelector('img[src="/api/photos/200/thumbnail"]')).not.toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-059: a clip whose poster fails to load is not retried as the clip itself', async () => {
// The /original of a video is the video file, which an <img> cannot draw.
const user = userEvent.setup()
const strip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/a.jpg' }
const picker = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: 'journey/b.jpg' }
const { container } = mountEditor(buildEntry({ id: 10, photos: [strip] }), { galleryPhotos: [picker] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
for (const id of [100, 200]) {
const img = container.querySelector(`img[src="/api/photos/${id}/thumbnail"]`) as HTMLImageElement
fireEvent.error(img)
expect(img.getAttribute('src')).toBe(`/api/photos/${id}/thumbnail`)
}
})
}) })
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, useRef } from 'react' import { useEffect, useMemo, useState, useRef, type SyntheticEvent } from 'react'
import { localIsoDate } from '../../utils/localDate' import { localIsoDate } from '../../utils/localDate'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } from 'lucide-react' import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic' import { normalizeImageFiles } from '../../utils/convertHeic'
@@ -21,6 +21,27 @@ import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourn
type PendingProviderGroup = ProviderPhotoGroup & { provider: string } 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 }: { export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
entry: JourneyEntry entry: JourneyEntry
journeyId: number 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" 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%' }} 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> </button>
))} ))}
{availableGalleryPhotos.length === 0 && ( {availableGalleryPhotos.length === 0 && (
@@ -528,13 +553,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{photos.map((p, idx) => ( {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' : ''}`}> <div key={p.id} className={`w-20 h-20 rounded-xl overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
{posterlessVideo(p) ? ( {posterlessVideo(p) ? (
// No poster to show and falling back to /original would hand <ClipTile />
// an <img> the clip itself, so this tile stays a play badge.
<div className="w-full h-full bg-black flex items-center justify-center text-white">
<Play size={18} className="ml-0.5" fill="currentColor" />
</div>
) : ( ) : (
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} /> <img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={thumbnailFallback(p)} />
)} )}
{idx === 0 && photos.length > 1 && ( {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> <span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
@@ -1,4 +1,4 @@
// FE-JRN-COVER-001 to FE-JRN-COVER-008 // FE-JRN-COVER-001 to FE-JRN-COVER-010
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
@@ -112,4 +112,23 @@ describe('JourneyEntryCover', () => {
expect(onClick).toHaveBeenCalledOnce() 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 { useTranslation } from '../../i18n'
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta' import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import CountryFlag from '../shared/CountryFlag' import CountryFlag from '../shared/CountryFlag'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard' import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard'
import type { JourneyEntry } from '../../store/journeyStore' import type { JourneyEntry } from '../../store/journeyStore'
@@ -25,6 +26,13 @@ import type { JourneyEntry } from '../../store/journeyStore'
* which needs no tokens at all. * 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. */ /** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */
export interface CoverEntry { export interface CoverEntry {
id: number id: number
@@ -39,7 +47,7 @@ export interface CoverEntry {
entry_time?: string | null entry_time?: string | null
mood?: string | null mood?: string | null
weather?: string | null weather?: string | null
photos?: CardPhoto[] photos?: CoverPhoto[]
} }
interface Props { interface Props {
@@ -69,8 +77,12 @@ export default function JourneyEntryCover({
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const isSuggestion = entry.type === 'skeleton' const isSuggestion = entry.type === 'skeleton'
const photoId = cardPhotoId(entry.photos?.[0]) const first = entry.photos?.[0]
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail` 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 title = cardTitle(entry, t)
const place = cardPlace(entry) 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" /> <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"> <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> </span>
)} )}
@@ -1,4 +1,4 @@
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017 // FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
import { render, screen, fireEvent } from '../../../tests/helpers/render' import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -234,4 +234,25 @@ describe('MobileEntryView', () => {
expect(screen.queryByText('Amazing')).not.toBeInTheDocument() expect(screen.queryByText('Amazing')).not.toBeInTheDocument()
expect(screen.queryByText('Sunny')).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 JournalBody from './JournalBody'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { formatLocationName } from '../../utils/formatters' import { formatLocationName } from '../../utils/formatters'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
// Labels are translation keys, like the desktop tables in // 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)} onClick={() => onPhotoClick(photos, 0)}
> >
<img {/* A clip is drawn from its poster, its original being the video file.
src={photoUrl(photos[0], 'original', publicPhotoUrl)} Without a poster the black ground and the play badge are the hero (#2341). */}
alt="" {posterlessVideo(photos[0]) ? (
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover' <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' && ( {photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <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)} onClick={() => onPhotoClick(photos, i)}
> >
<img {!posterlessVideo(p) && (
src={photoUrl(p, 'thumbnail', publicPhotoUrl)} <img
alt="" src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover' alt=""
}`} className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
/> }`}
/>
)}
{p.media_type === 'video' && ( {p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -3,10 +3,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
const plyrCtor = vi.fn() const plyrCtor = vi.fn()
vi.mock('plyr', () => ({ vi.mock('plyr', () => ({
default: class { default: class {
el: HTMLElement
original: Node
constructor(el: HTMLElement, options: Record<string, unknown>) { constructor(el: HTMLElement, options: Record<string, unknown>) {
plyrCtor(el, options) plyrCtor(el, options)
this.el = el
this.original = el.cloneNode(true)
}
// What the real destroy() does to the document: the element as it was at
// construction goes back in, whatever React wrote to it since.
destroy() {
this.el.replaceWith(this.original)
} }
destroy = vi.fn()
}, },
})) }))
vi.mock('plyr/dist/plyr.css', () => ({})) vi.mock('plyr/dist/plyr.css', () => ({}))
@@ -31,7 +39,7 @@ describe('VideoPlayer', () => {
it('resets the element to an empty source on teardown rather than a clip on the CDN', () => { 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 // media-src refuses cdn.plyr.io too; an empty source aborts the stream just the
// same, and the element is unmounted in the same tick. // same, and the element is out of the document by then.
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />) render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
const [, options] = plyrCtor.mock.calls[0] const [, options] = plyrCtor.mock.calls[0]
@@ -45,4 +53,19 @@ describe('VideoPlayer', () => {
expect(el).toBe(container.querySelector('video')) expect(el).toBe(container.querySelector('video'))
expect(container.querySelector('video')).toHaveAttribute('poster', '/api/photos/7/thumbnail') 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')
})
}) })
+20 -14
View File
@@ -7,24 +7,30 @@ import 'plyr/dist/plyr.css'
// host and would fetch a data URL over XHR, which connect-src refuses as well. // host and would fetch a data URL over XHR, which connect-src refuses as well.
import plyrSprite from 'plyr/dist/plyr.svg?no-inline' import plyrSprite from 'plyr/dist/plyr.svg?no-inline'
interface Props {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}
/** /**
* Video player for gallery/lightbox playback (#823), built on Plyr over a native * Video player for gallery/lightbox playback (#823), built on Plyr over a native
* <video>. Local videos stream with HTTP Range (seeking works out of the box) and * <video>. Local videos stream with HTTP Range (seeking works out of the box) and
* the source carries the correct video MIME from the server. The Plyr instance is * 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 * created once per mounted source and destroyed on unmount, so navigating away in
* the lightbox stops playback. * 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({ export default function VideoPlayer(props: Props): React.ReactElement {
src, return <PlyrVideo key={props.src} {...props} />
poster, }
autoPlay = true,
style, function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactElement {
}: {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}): React.ReactElement {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => { useEffect(() => {
@@ -48,8 +54,8 @@ export default function VideoPlayer({
iconUrl: plyrSprite, iconUrl: plyrSprite,
// On teardown Plyr points the element at a blank clip to abort the stream, // 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 // by default one on cdn.plyr.io, which media-src refuses. An empty source
// aborts the stream just the same, and the element is unmounted in the same // aborts the stream just the same, and the element is out of the document
// tick, so the error it would otherwise report has nowhere to show. // by then, so the error it would otherwise report has nowhere to show.
blankVideo: '', blankVideo: '',
}) })
@@ -60,7 +66,7 @@ export default function VideoPlayer({
/* already torn down */ /* already torn down */
} }
} }
}, [src, autoPlay]) }, [autoPlay])
return ( return (
<div <div
@@ -1030,6 +1030,26 @@ describe('MapView live location', () => {
expect(geoMock.cycleMode).toHaveBeenCalled() 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', () => { it('FE-COMP-MAPVIEW-077: the map draws no credit control of its own, on either width', () => {
const desktop = render(<MapView />) const desktop = render(<MapView />)
// The desktop credit is Leaflet's own, in its own container; this component adds none. // The desktop credit is Leaflet's own, in its own container; this component adds none.
+5 -1
View File
@@ -547,6 +547,7 @@ import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } f
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useGeolocation } from '../../hooks/useGeolocation' import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton' import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
// Live-location rendering inside the Leaflet map. Subscribes via the // Live-location rendering inside the Leaflet map. Subscribes via the
// shared useGeolocation hook so the Leaflet and Mapbox variants behave // 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() const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy), // Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it. // 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, // 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 // 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 // above it; otherwise keep the plain bottom-nav offset. #1348
+7 -3
View File
@@ -22,6 +22,7 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor' import { safeHexColor } from '../../utils/safeColor'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton' import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation' import { useGeolocation } from '../../hooks/useGeolocation'
import type { Day, Place, Reservation, RouteVia } from '../../types' import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover' import type { MapHoverInfo } from './mapHover'
@@ -1159,6 +1160,12 @@ export function MapViewGL({
const onMapReadyRef = useRef(onMapReady) const onMapReadyRef = useRef(onMapReady)
onMapReadyRef.current = onMapReady onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation() 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 }) const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick onClickRefs.current.marker = onMarkerClick
onClickRefs.current.map = onMapClick 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, // 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 // 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 // above it; otherwise keep the plain bottom-nav offset. #1348
+34 -1
View File
@@ -31,6 +31,12 @@ function getIframe(): HTMLIFrameElement | null {
return document.querySelector('#pdf-preview-overlay iframe') 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 ───────────────────────────────────────────────────────────────────── // ── Setup ─────────────────────────────────────────────────────────────────────
beforeEach(() => { beforeEach(() => {
@@ -257,7 +263,7 @@ describe('downloadTripPDF', () => {
// The desktop toolbar hands the export the store's assignments, hidden stop and // 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 // 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. // booking as the accommodation block, and the print lists what the plan lists.
const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null } as any const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null, price: '120' } as any
const hotelStop = { id: 201, day_id: 10, place_id: 101, order_index: 0, accommodation_id: 30, place: hotelPlace } const 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' } const flight = { ...transportReservation, title: 'Morning flight', reservation_time: '2025-06-01T08:00:00' }
await downloadTripPDF({ await downloadTripPDF({
@@ -269,6 +275,33 @@ describe('downloadTripPDF', () => {
expect(html).not.toContain('Seewartenstr. 9') expect(html).not.toContain('Seewartenstr. 9')
expect(html.indexOf('Morning flight')).toBeLessThan(html.indexOf('Colosseum')) expect(html.indexOf('Morning flight')).toBeLessThan(html.indexOf('Colosseum'))
expect(html.indexOf('Morning flight')).toBeGreaterThan(-1) 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 () => { it('FE-COMP-TRIPPDF-013c: a flight that lands the same day shows both times (#1310)', async () => {
+31 -10
View File
@@ -16,6 +16,7 @@ import { renderTripMapImage } from './tripMapImage'
import { formatDistance } from '../../utils/units' import { formatDistance } from '../../utils/units'
import { fetchExchangeRates } from '../../hooks/useExchangeRates' import { fetchExchangeRates } from '../../hooks/useExchangeRates'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs' 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 * Every day starts a new page by default. On a trip of short days that prints
@@ -199,11 +200,34 @@ interface downloadTripPDFProps {
timeFormat?: string timeFormat?: string
/** 'metric' | 'imperial'. Same reasoning as `timeFormat` — read as a prop, not a hook. */ /** 'metric' | 'imperial'. Same reasoning as `timeFormat` — read as a prop, not a hook. */
distanceUnit?: string 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. * The stops the day plan lists, out of everything the store holds for the trip.
export async function downloadTripPDF({ trip, days, places, assignments = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit }: downloadTripPDFProps) { *
* 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 breaksPerDay = pageBreakPerDay()
const loc = _locale || undefined const loc = _locale || undefined
const tr = _t || (k => k) const tr = _t || (k => k)
@@ -335,14 +359,11 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
} }
// Build day HTML // Build day HTML
const daysHtml = sorted.map((day, di) => { const daysHtml = sorted.map((day, di) => {
// Without the stop a booked night wrote onto its check-in day: the day plan hides // What the plan lists (planAssignments): the stop a booked night wrote sat at the
// it (the day already carries the booking as its accommodation block) and the // head of its day, so the hotel printed above a morning flight (#2434).
// export lists what the plan lists. The desktop toolbar hands the export the
// store's own assignments, filter and all, and the stop sits at the head of its
// day, so the hotel printed above a morning flight (#2434).
const assigned = (assignments[String(day.id)] || []) const assigned = (assignments[String(day.id)] || [])
.filter((a: any) => a.accommodation_id == null) .slice()
.sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0)) .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
const notes = (dayNotes || []).filter(n => n.day_id === day.id).slice() const notes = (dayNotes || []).filter(n => n.day_id === day.id).slice()
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) .sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
const cost = dayCost(assignments, day.id, loc, tripCur, fxRates) const cost = dayCost(assignments, day.id, loc, tripCur, fxRates)
@@ -101,14 +101,15 @@ vi.mock('../shared/Toast', () => ({
// ── Permissions mock ──────────────────────────────────────────────────────── // ── Permissions mock ────────────────────────────────────────────────────────
// Flipped per test to render the read-only variants of the day rows. // Flipped per test to render the read-only variants of the day rows; `denied`
const mockPermissions = vi.hoisted(() => ({ canEdit: true })) // 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) => { vi.mock('../../store/permissionsStore', async (importOriginal) => {
const actual = await importOriginal() as any const actual = await importOriginal() as any
return { return {
...actual, ...actual,
useCanDo: () => () => mockPermissions.canEdit, useCanDo: () => (action: string) => mockPermissions.canEdit && !mockPermissions.denied.has(action),
} }
}) })
@@ -182,6 +183,7 @@ beforeEach(() => {
resetAllStores() resetAllStores()
vi.clearAllMocks() vi.clearAllMocks()
mockPermissions.canEdit = true mockPermissions.canEdit = true
mockPermissions.denied.clear()
// clearAllMocks keeps implementations, so tests that swap the router out would // clearAllMocks keeps implementations, so tests that swap the router out would
// otherwise leak into the ones after them. // otherwise leak into the ones after them.
vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({ vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({
@@ -938,6 +940,18 @@ describe('DayPlanSidebar', () => {
expect(onEditPlace).toHaveBeenCalledWith(place, assignment.id) 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 ──────────────────────────────────────────────── // ── Arrow reorder buttons ────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-041: arrow down button reorders day assignments', async () => { 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 tripActions = useRef(useTripStore.getState()).current
const can = useCanDo() const can = useCanDo()
const canEditDays = can('day_edit', trip) 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 // 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. // account, so it sits behind the same permission as the public share link.
const canManageShare = can('share_manage', trip) const canManageShare = can('share_manage', trip)
@@ -1148,6 +1151,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
tripActions, tripActions,
can, can,
canEditDays, canEditDays,
canEditPlaces,
canManageShare, canManageShare,
noteUi, noteUi,
setNoteUi, setNoteUi,
@@ -1348,6 +1352,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
tripActions, tripActions,
can, can,
canEditDays, canEditDays,
canEditPlaces,
canManageShare, canManageShare,
noteUi, noteUi,
setNoteUi, setNoteUi,
@@ -2097,13 +2102,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// does not exist here. // does not exist here.
const navTargets = getNavigationTargets(place) const navTargets = getNavigationTargets(place)
ctxMenu.open(e, [ 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) }, 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') }, 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) })), ...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) }, collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true }, { 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 => { onMouseEnter={e => {
@@ -233,6 +233,81 @@ describe('PlaceFormModal', () => {
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull(); 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 () => { it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
@@ -18,7 +18,7 @@ import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isMapUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers' import { DEFAULT_FORM, isMapUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError' import { getApiErrorMessage } from '../../utils/apiError'
import { offersGoogleRetry, sourceLabelFor } from '../../utils/placeSource' import { offersGoogleRetry, selectGoogleHoldsSlot, sourceLabelFor } from '../../utils/placeSource'
import { useLocationBias } from '../../hooks/useLocationBias' import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection' import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types' import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -196,7 +196,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const placesSessionRef = useRef(new PlacesSession()) const placesSessionRef = useRef(new PlacesSession())
const toast = useToast() const toast = useToast()
const { t, language, locale } = useTranslation() const { t, language, locale } = useTranslation()
const { hasMapsKey, placesEnrichEnabled } = useAuthStore() const { placesEnrichEnabled } = useAuthStore()
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const can = useCanDo() const can = useCanDo()
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h' const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
const tripObj = useTripStore((s) => s.trip) const tripObj = useTripStore((s) => s.trip)
@@ -397,12 +398,15 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
} }
const handleMapsSearch = async (provider?: 'google') => { const handleMapsSearch = async (provider?: 'google') => {
if (!mapsSearch.trim()) return // The retry sends the query the list came from, not the field: the list
// stays on screen while the field is edited or cleared, and the line under
// it promises the same query.
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : mapsSearch.trim()
if (!trimmed) return
setIsSearchingMaps(true) setIsSearchingMaps(true)
try { try {
// A pasted Google Maps or Amap link resolves server-side into a place // A pasted Google Maps or Amap link resolves server-side into a place
const trimmed = mapsSearch.trim() if (!provider && isMapUrl(trimmed)) {
if (isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed) const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) { if (resolved.lat && resolved.lng) {
setForm(prev => ({ setForm(prev => ({
@@ -419,8 +423,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
return return
} }
} }
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint, provider) const result = await mapsApi.search(trimmed, language, locationBiasPoint, provider)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' } searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
setMapsResults(result.places || []) setMapsResults(result.places || [])
setSearchSource(result.source || '') setSearchSource(result.source || '')
} catch (err: unknown) { } catch (err: unknown) {
@@ -760,7 +764,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
language, language,
locale, locale,
timeFormat, timeFormat,
hasMapsKey, googleAnswers,
placesEnrichEnabled, placesEnrichEnabled,
can, can,
tripObj, tripObj,
@@ -838,7 +842,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
toast, toast,
t, t,
language, language,
hasMapsKey, googleAnswers,
placesEnrichEnabled, placesEnrichEnabled,
can, can,
tripObj, tripObj,
@@ -1005,8 +1009,9 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
{/* The index answers first and Google only when it finds nothing, so a {/* 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 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 quiet line under the list sends the same query there, on an instance
that has a key and for a list Google did not already produce. */} where Google holds the key slot and for a list Google did not
{mapsResults.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && ( already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
<button <button
type="button" type="button"
onClick={() => handleMapsSearch('google')} onClick={() => handleMapsSearch('google')}
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore'; import { useAddonStore } from '../../store/addonStore';
import { usePluginStore } from '../../store/pluginStore'; import { usePluginStore } from '../../store/pluginStore';
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'; import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server'; import { server } from '../../../tests/helpers/msw/server';
import type { AssignmentsMap } from '../../types'; import type { AssignmentsMap } from '../../types';
@@ -1478,6 +1479,25 @@ describe('PlaceInspector', () => {
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press'); 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', () => { 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 // 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 type { CollectionStatus } from '@trek/shared'
import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters' import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { formatDistance, formatElevation } from '../../utils/units' import { formatDistance, formatElevation } from '../../utils/units'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation' import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge' import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
@@ -184,11 +185,21 @@ interface PlaceInspectorProps {
export default function PlaceInspector({ export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null, place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [], onEditTransport, onEditReservation, assignments = {}, reservations = [], onEditTransport, onEditReservation,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment, onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate, files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onRate,
leftWidth = 0, rightWidth = 0, leftWidth = 0, rightWidth = 0,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive, collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) { }: 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, // 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. // 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') const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail')
@@ -196,9 +196,9 @@ export function PlacesHeader(S: SidebarState) {
{/* Says out loud what the count above already narrowed to. {/* 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 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 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 conclusion was that the map was broken. It sits under the filter select
the map because a chip over the canvas is unreachable on a phone, which is rather than on the map because a chip over the canvas is unreachable on a
where this was reported from. */} phone, which is where this was reported from. */}
{dayScoped && ( {dayScoped && (
<div <div
className="border border-edge-faint bg-surface-tertiary text-content-secondary" 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 user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1)) await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({ 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 { useToast } from '../shared/Toast'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
/** /**
* What a GPX download can carry. Worded by what someone wants on their device * 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). // The PDF is built outside React, so it cannot read this itself (#2066).
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) 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' const fileBase = trip?.title || 'trip'
// Shared tail of every download: Firefox and Safari cancel the download when // 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 // exported. A missing chunk lands in the catch and shows the same error
// the export already had. // the export already had.
const { downloadTripPDF } = await import('../PDF/TripPDF') 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() onClose()
} catch (e) { } catch (e) {
console.error('PDF error:', e) console.error('PDF error:', e)
@@ -80,6 +80,31 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
} }
} }
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
legIndex: 0,
intoLegKm: 182,
drivenMeters: 182000,
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
// traveller's own setting, so a band printing it would read the same figure on
// every leg of the day.
sinceKm: 600,
lat: 52.4,
lng: 10.2,
...over,
})
/** Idle unless a case says which part of the search it is standing in. */
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
openFor: null,
loading: false,
outcome: null,
results: [],
offered: [],
ask: vi.fn(),
close: vi.fn(),
...over,
})
describe('RoadtripSidebar', () => { describe('RoadtripSidebar', () => {
/** /**
* The value the summary head pairs with this label. The same words also name the chips * 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() expect(onReorderStop).not.toHaveBeenCalled()
}) })
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car', () => { 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 ferry = leg({ mode: 'ferry' }) const unknown = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [leg({ mode: 'hovercraft' })] })] })} />)
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [ferry] })] })} />) 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', () => { 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. * 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', () => { 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 => ({ const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
osm_id: `osm-${name}`, osm_id: `osm-${name}`,
name, name,
@@ -1179,6 +1184,54 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1) expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
expect(screen.getAllByLabelText('Other ways')).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)', () => { describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () => {
@@ -1194,9 +1247,9 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
const booking = (over: Partial<Reservation>): Reservation => const booking = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation ({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => { const rentalDay = () => {
const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')] const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
const rental = day({ return day({
stops, stops,
legs: [leg(), leg(), leg()], legs: [leg(), leg(), leg()],
schedule: { schedule: {
@@ -1209,8 +1262,11 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
warnings: [], 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() const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [rental], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />) wrap(<RoadtripSidebar routes={routes({ days: [rentalDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
expect(screen.getByText('Pick-up 09:00')).toBeInTheDocument() expect(screen.getByText('Pick-up 09:00')).toBeInTheDocument()
expect(screen.getByText('Return 11:30')).toBeInTheDocument() expect(screen.getByText('Return 11:30')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument() expect(screen.getByText('HAM')).toBeInTheDocument()
@@ -1223,6 +1279,15 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
expect(screen.getAllByLabelText('Other ways')).toHaveLength(2) 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', () => { 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 stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [ const reservations = [
@@ -1233,7 +1298,7 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
booking({ id: 15, title: 'Hotel Hafen', type: 'hotel', place_id: 10 }), booking({ id: 15, title: 'Hotel Hafen', type: 'hotel', place_id: 10 }),
] ]
const onOpenBooking = vi.fn() const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />) wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} canEditBookings />)
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument() expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
expect(screen.getByText('19:30')).toBeInTheDocument() expect(screen.getByText('19:30')).toBeInTheDocument()
expect(screen.getByText('Café Niederegger')).toBeInTheDocument() expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
@@ -1247,4 +1312,21 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
// The chips are the stop's, not stops: the count stays at two. // The chips are the stop's, not stops: the count stays at two.
expect(screen.getByText('2 stops')).toBeInTheDocument() expect(screen.getByText('2 stops')).toBeInTheDocument()
}) })
it('FE-ROADTRIP-SIDEBAR-058: for a reader who may not edit bookings, a table is a plain chip while a taxi still opens its detail view', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
booking({ id: 12, title: 'Taxi zum Hafen', type: 'taxi', reservation_time: '2026-10-05T18:30', assignment_id: 1 }),
]
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
// The table is there to read, not a button that does nothing; the taxi is a button.
expect(screen.getByText('Tisch Bullerei').closest('button')).toBeNull()
expect(screen.getByText('Taxi zum Hafen').closest('button')).not.toBeNull()
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(onOpenBooking).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Taxi zum Hafen'))
expect(onOpenBooking).toHaveBeenCalledWith(12)
})
}) })
@@ -23,6 +23,7 @@ import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker' import StopFillPicker from './StopFillPicker'
import { useVehicleRange } from './useVehicleRange' import { useVehicleRange } from './useVehicleRange'
import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared' 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 { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { SpillMark } from './nightSpill' import type { SpillMark } from './nightSpill'
import { dayColor } from './dayColors' import { dayColor } from './dayColors'
@@ -31,7 +32,7 @@ import { FS } from './typeScale'
import type { RouteSegment } from '../../types' import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop' import AutomaticDayStop from './AutomaticDayStop'
import { carrierIcon, rideText, terminalLine } from './carrierRide' import { bookingOpens, carrierIcon, rideText, terminalLine } from './carrierRide'
import { bookingClock, bookingIcon, dayBookings } from './stopBookings' import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
import type { Reservation } from '../../types' import type { Reservation } from '../../types'
import type { StayDraft } from './RoadtripStayModal' import type { StayDraft } from './RoadtripStayModal'
@@ -115,6 +116,12 @@ interface RoadtripSidebarProps {
* the table or the tickets under a stop. Absent leaves them all as plain text. * the table or the tickets under a stop. Absent leaves them all as plain text.
*/ */
onOpenBooking?: (reservationId: number) => void 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> = { const MODE_ICON: Record<string, LucideIcon> = {
@@ -683,8 +690,10 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
) )
} }
function DriveBand({ leg, onAskAlternatives, alternativesOpen }: { function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: {
leg: RouteSegment | undefined 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. */ /** Asks for other ways of driving this leg. Absent means the route is not editable. */
onAskAlternatives?: () => void onAskAlternatives?: () => void
alternativesOpen?: boolean alternativesOpen?: boolean
@@ -692,10 +701,14 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
const { t } = useTranslation() const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const mode = leg?.mode ?? 'driving' 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 // 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. // nothing to say about it and no other way to drive it.
if (isHop(leg)) { if (!ride && isHop(leg)) {
return ( return (
<div className="grid" style={RAIL_GRID}> <div className="grid" style={RAIL_GRID}>
<span className="relative z-[1] flex min-h-[10px] flex-col items-center" aria-hidden> <span className="relative z-[1] flex min-h-[10px] flex-col items-center" aria-hidden>
@@ -711,12 +724,14 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
<> <>
<Icon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden /> <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 }}> <span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
{leg {!leg
? t('roadtrip.leg.driveText', { ? t('roadtrip.leg.pending')
distance: formatDistance(leg.distance / 1000, distanceUnit), : ride
time: formatDurationShort(leg.duration), ? rideText(carrier, leg)
}) : t('roadtrip.leg.driveText', {
: t('roadtrip.leg.pending')} distance: formatDistance(leg.distance / 1000, distanceUnit),
time: formatDurationShort(leg.duration),
})}
</span> </span>
</> </>
) )
@@ -782,27 +797,22 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
* the time the chain wants the traveller there, which for a departure is a check-in ahead * 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. * of the timetable. The whole row opens the booking.
*/ */
function TerminalStop({ stop, entry, late, selected, continues, starts, onOpen }: { function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
stop: RoadtripStop stop: RoadtripStop
entry: ScheduleEntry | undefined entry: ScheduleEntry | undefined
late: ScheduleWarning[] late: ScheduleWarning[]
selected: boolean
continues: boolean continues: boolean
starts?: boolean starts?: boolean
onOpen?: () => void onOpen?: () => void
}): React.ReactElement { }): React.ReactElement {
const { t } = useTranslation() const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const Icon = carrierIcon(stop.carrier!.type)
const carrier = stop.carrier!
const Icon = carrierIcon(carrier.type)
const line = terminalLine(carrier, t, is12h)
return ( return (
<button <button
type="button" type="button"
onClick={onOpen} onClick={onOpen}
disabled={!onOpen} disabled={!onOpen}
aria-label={onOpen ? t('roadtrip.ride.open') : undefined} aria-label={onOpen ? t('roadtrip.ride.open') : undefined}
aria-current={selected ? 'true' : undefined}
className="group grid w-full rounded-lg text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent disabled:cursor-default" 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} style={RAIL_GRID}
> >
@@ -813,38 +823,17 @@ function TerminalStop({ stop, entry, late, selected, continues, starts, onOpen }
</span> </span>
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null} {continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
</span> </span>
<span <span className="min-w-0 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
className={`flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors ${ <RideEnd stop={stop} entry={entry} late={late} />
selected ? 'bg-surface-selected' : 'group-hover:bg-surface-hover'
}`}
>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content"
style={{ fontSize: FS.name }}
>
<span className="min-w-0 break-words">{stop.name}</span>
{carrier.code ? (
<span className="shrink-0 font-geist font-medium text-content-faint" style={{ fontSize: FS.meta }}>{carrier.code}</span>
) : null}
</span>
<span className="flex flex-wrap items-center gap-1">
{line ? (
<span className="text-content-muted" style={{ fontSize: FS.meta }}>{line}</span>
) : null}
{late.map(w => <LateBadge key={w.code} late={w} />)}
</span>
</span>
{entry?.arrival ? <Arrival entry={entry} /> : null}
</span> </span>
</button> </button>
) )
} }
/** /**
* One end of a ride inside the ride's block: the terminal's name and code, the * One end of a ride: the terminal's name and code, the timetable's clock under it, and
* timetable's clock under it, and on the right the time the chain has the traveller * on the right the time the chain has the traveller there. Inside the ride's block for
* there. The same three things the lone terminal row shows, without a row of its own. * a same-day ride, and the whole of a lone terminal's row.
*/ */
function RideEnd({ stop, entry, late }: { function RideEnd({ stop, entry, late }: {
stop: RoadtripStop stop: RoadtripStop
@@ -856,7 +845,7 @@ function RideEnd({ stop, entry, late }: {
const line = terminalLine(stop.carrier!, t, is12h) const line = terminalLine(stop.carrier!, t, is12h)
return ( return (
<span className="flex min-w-0 items-start gap-2"> <span className="flex min-w-0 items-start gap-2">
<span className="flex min-w-0 flex-1 flex-col"> <span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}> <span className="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> <span className="min-w-0 break-words">{stop.name}</span>
{stop.carrier!.code ? ( {stop.carrier!.code ? (
@@ -932,9 +921,11 @@ function RideBlock({ departure, arrival, entries, late, seg, continues, starts,
* Under the stop's button rather than inside it, because a button cannot hold buttons. * 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. * The rail keeps its line through the row so the chain reads unbroken.
*/ */
function BookingChips({ bookings, continues, onOpen }: { function BookingChips({ bookings, continues, canEdit, onOpen }: {
bookings: Reservation[] bookings: Reservation[]
continues: boolean continues: boolean
/** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
canEdit: boolean
onOpen?: (reservationId: number) => void onOpen?: (reservationId: number) => void
}): React.ReactElement { }): React.ReactElement {
const { t } = useTranslation() const { t } = useTranslation()
@@ -956,7 +947,7 @@ function BookingChips({ bookings, continues, onOpen }: {
</> </>
) )
const chip = 'inline-flex h-[20px] max-w-full items-center gap-1 rounded-md border border-edge bg-surface-card px-1.5 text-content-secondary' const chip = 'inline-flex h-[20px] max-w-full items-center gap-1 rounded-md border border-edge bg-surface-card px-1.5 text-content-secondary'
return onOpen ? ( return onOpen && bookingOpens(r, canEdit) ? (
<Tooltip key={r.id} label={t('roadtrip.ride.open')}> <Tooltip key={r.id} label={t('roadtrip.ride.open')}>
<button <button
type="button" type="button"
@@ -1517,7 +1508,7 @@ function SpillBlock({ spill, children }: {
* move, a stay edit or a refuel offer still names the day the server knows it by. See * move, a stay edit or a refuel offer still names the day the server knows it by. See
* `nightSpill.ts`. * `nightSpill.ts`.
*/ */
function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: { function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, dayOrder, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
onFocusPoint?: RoadtripSidebarProps['onFocusPoint'] onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
day: RoadtripDay day: RoadtripDay
/** Folded down to the header, and off the map with it. */ /** Folded down to the header, and off the map with it. */
@@ -1532,7 +1523,10 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives'] onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives']
openAlternatives?: RoadtripSidebarProps['openAlternatives'] openAlternatives?: RoadtripSidebarProps['openAlternatives']
onOpenBooking?: RoadtripSidebarProps['onOpenBooking'] onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
canEditBookings?: boolean
reservations?: Reservation[] 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'] onEditStay?: RoadtripSidebarProps['onEditStay']
onSetStopKind?: RoadtripSidebarProps['onSetStopKind'] onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
onSetStopFill?: RoadtripSidebarProps['onSetStopFill'] onSetStopFill?: RoadtripSidebarProps['onSetStopFill']
@@ -1549,7 +1543,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
// Which disc the picker hangs under, and for which stop. One at a time: two open // 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. // popovers over the same rail is two answers to one question.
const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const bookings = useMemo(() => dayBookings(day, reservations ?? []), [day, reservations]) const bookings = useMemo(() => dayBookings(day, reservations ?? [], dayOrder), [day, reservations, dayOrder])
const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const { t, language } = useTranslation() const { t, language } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
@@ -1574,16 +1568,36 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
*/ */
const latenessAt = (i: number): ScheduleWarning[] => const latenessAt = (i: number): ScheduleWarning[] =>
day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave')) 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 => { const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement | null => {
if (stop.automaticNight) return ( if (stop.automaticNight) return (
<li key={stop.assignmentId}> <li key={stop.assignmentId}>
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} /> <AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
{i < last && day.legs[i]?.distance !== 0 ? <DriveBand leg={day.legs[i]} /> : null} {i < last && day.legs[i]?.distance !== 0 ? <DriveBand leg={day.legs[i]} /> : null}
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === i).map(dry => ( {refuelBandsFor(i)}
<RefuelBand key={`dry-${dry.legIndex}`} dry={dry} dayId={day.dayId} refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
)) : null}
{i < last ? (day.legVias[i] ?? []).map((via, vi) => ( {i < last ? (day.legVias[i] ?? []).map((via, vi) => (
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} /> <RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
)) : null} )) : null}
@@ -1626,15 +1640,20 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
stop={stop} stop={stop}
entry={day.schedule.entries[i]} entry={day.schedule.entries[i]}
late={lateness} late={lateness}
selected={false}
continues={i < last} continues={i < last}
starts={i === 0} starts={i === 0}
onOpen={open} onOpen={open}
/> />
)} )}
{tail < last && (!after?.automaticNight || day.legs[tail]?.distance !== 0) ? ( {tail < last && (!after?.automaticNight || day.legs[tail]?.distance !== 0) ? (
<DriveBand leg={day.legs[tail]} /> /* The row the road leaves from carries the booking whenever that road is a
ride itself (a departure terminal seated mid-day), and the band needs it
to say more than a bare icon. */
<DriveBand leg={day.legs[tail]} carrier={day.stops[tail]?.carrier} />
) : null} ) : 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) => ( {tail < last ? (day.legVias[tail] ?? []).map((via, vi) => (
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} /> <RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
)) : null} )) : null}
@@ -1708,7 +1727,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
/> />
)} )}
{bookings.atStop.has(i) ? ( {bookings.atStop.has(i) ? (
<BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} onOpen={onOpenBooking} /> <BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
) : null} ) : null}
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? ( {i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
<DriveBand <DriveBand
@@ -1717,28 +1736,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i} alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
/> />
) : null} ) : null}
{/* Only on the leg the fuel actually runs out on, and only once the day has {refuelBandsFor(i)}
finished routing: the warnings are republished after every routing task
and the early ones are wrong, so an offer that appears and moves while
the trip loads reads as a fault. */}
{refuel && !loading
? (day.dryPoints ?? [])
.filter(dry => dry.legIndex === i)
.map(dry => (
<RefuelBand
key={`dry-${dry.legIndex}`}
dry={dry}
// The card's day, not the stop's stored one. This is the key the open
// search is filed under, and the hook is asked with the same number —
// a band inside a borrowed stretch had them differ, so the results
// arrived, drew on the map, and the band they belonged to never opened.
dayId={day.dayId}
refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined}
/>
))
: null}
{/* After the band, because a plugin halt happens on the drive it describes {/* After the band, because a plugin halt happens on the drive it describes
rather than before setting off. */} rather than before setting off. */}
{i < last ? (day.legVias[i] ?? []).map((via, vi) => ( {i < last ? (day.legVias[i] ?? []).map((via, vi) => (
@@ -1755,18 +1753,15 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
* Built rather than mapped straight through, because a spill is drawn inside a block of * 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`. * its own and a block cannot be opened halfway down a `map`.
*/ */
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => ( const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => {
<React.Fragment key={stop.assignmentId}> const inbound = refuelBandsFor(-i - 1)
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === -i - 1).map(dry => ( return (
<li key={`inbound-${i}`}> <React.Fragment key={stop.assignmentId}>
<RefuelBand dry={dry} dayId={day.dayId} refuel={refuel} {inbound?.length ? <li key={`inbound-${i}`}>{inbound}</li> : null}
onAsk={() => onAskRefuel?.(day.dayId, dry)} {renderStopContent(stop, i)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} /> </React.Fragment>
</li> )
)) : null} }
{renderStopContent(stop, i)}
</React.Fragment>
)
const runs: { spill: SpillMark | null; from: number; to: number }[] = [] const runs: { spill: SpillMark | null; from: number; to: number }[] = []
{ {
let at = 0 let at = 0
@@ -1909,7 +1904,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
happens. Without it a morning that starts at 08:11 after a stay that ended at 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 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. */} 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 => ( {runs.map(run => (
run.spill ? ( run.spill ? (
<SpillBlock key={`spill-${run.from}`} spill={run.spill}> <SpillBlock key={`spill-${run.from}`} spill={run.spill}>
@@ -1930,7 +1927,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, re
<span aria-hidden /> <span aria-hidden />
<span className={`${STAT_LABEL} px-1.5`} style={{ fontSize: FS.micro }}>{t('roadtrip.bookings.loose')}</span> <span className={`${STAT_LABEL} px-1.5`} style={{ fontSize: FS.micro }}>{t('roadtrip.bookings.loose')}</span>
</div> </div>
<BookingChips bookings={bookings.loose} continues={false} onOpen={onOpenBooking} /> <BookingChips bookings={bookings.loose} continues={false} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
</li> </li>
) : null} ) : null}
</ol> </ol>
@@ -2033,7 +2030,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
* them is a pill just for being a number. * them is a pill just for being a number.
*/ */
export default function RoadtripSidebar({ export default function RoadtripSidebar({
routes, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay, routes, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel, onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
collapsedDayIds, onToggleDay, onFocusPoint, collapsedDayIds, onToggleDay, onFocusPoint,
}: RoadtripSidebarProps): React.ReactElement { }: RoadtripSidebarProps): React.ReactElement {
@@ -2043,6 +2040,13 @@ export default function RoadtripSidebar({
const [from, setFrom] = React.useState<DragState['from']>(null) const [from, setFrom] = React.useState<DragState['from']>(null)
const [dropAt, setDropAt] = React.useState<DragState['dropAt']>(null) const [dropAt, setDropAt] = React.useState<DragState['dropAt']>(null)
const drag: DragState = { from, setFrom, dropAt, setDropAt } 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". // Nothing to total up, so nothing pretends to: no "0 km" standing above "No route yet".
if (routes.days.length === 0) { if (routes.days.length === 0) {
@@ -2092,7 +2096,9 @@ export default function RoadtripSidebar({
onAskAlternatives={onAskAlternatives} onAskAlternatives={onAskAlternatives}
openAlternatives={openAlternatives} openAlternatives={openAlternatives}
onOpenBooking={onOpenBooking} onOpenBooking={onOpenBooking}
canEditBookings={canEditBookings}
reservations={reservations} reservations={reservations}
dayOrder={dayOrder}
onEditStay={onEditStay} onEditStay={onEditStay}
onSetStopKind={onSetStopKind} onSetStopKind={onSetStopKind}
onSetStopFill={onSetStopFill} onSetStopFill={onSetStopFill}
+16 -3
View File
@@ -1,12 +1,13 @@
import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react' import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
import { formatDurationShort } from './roadtripModel' import { formatDurationShort } from './roadtripModel'
import { formatClockTime } from '../../utils/formatters' import { formatClockTime } from '../../utils/formatters'
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip' import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../types' import type { Reservation, TranslationFn } from '../../types'
/** /**
* How a ride and its two terminals, and a hire car's two desks, read on the rail and in * How a ride and its two terminals, and a hire car's two desks, read on the rail and in
* the phone chain (#2428). * the phone chain (#2428), and which of the bookings under a stop open there.
* *
* One module for both shells, because the two would otherwise carry the same icon table * 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. * and the same three sentences each, and the duplication budget does not stretch to that.
@@ -39,9 +40,21 @@ export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h:
/** /**
* What the ride's pill says: the booking, then how long it takes when the timetable gives * 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. * both ends. A ride with no minutes is still the booking, and says so without a duration.
* A ride leg met without its booking is just the minutes.
*/ */
export function rideText(carrier: CarrierTerminal, seg: RouteSegment | undefined): string { export function rideText(carrier: Pick<CarrierTerminal, 'title'> | undefined, seg: RouteSegment | undefined): string {
const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0 const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '') const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '')
if (!carrier) return duration
return duration ? `${carrier.title} · ${duration}` : carrier.title return duration ? `${carrier.title} · ${duration}` : carrier.title
} }
/**
* Whether a booking chip opens for this reader. A transport has a detail view anybody
* may look at; a table or a ticket has only its editor, the split the planner page makes
* when it opens one. Without the right to edit bookings the chip stays a plain chip,
* the way the place inspector's booking strip stays inert (#2012).
*/
export function bookingOpens(booking: Pick<Reservation, 'type'>, canEditBookings: boolean): boolean {
return TRANSPORT_TYPES.has(booking.type) || canEditBookings
}
+2 -2
View File
@@ -1,2 +1,2 @@
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround } 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 } 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 { ScheduleEntry, ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip' 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 { function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return { return {
@@ -604,4 +604,48 @@ describe('roadtripRows with a ride (#2428)', () => {
expect(firstStopOfPlace([flightDay()], -7)).toBeNull() expect(firstStopOfPlace([flightDay()], -7)).toBeNull()
expect(firstStopOfPlace([flightDay()], 100)?.name).toBe('Bremen') expect(firstStopOfPlace([flightDay()], 100)?.name).toBe('Bremen')
}) })
/** Bremen at 09:00, the airport at 12:20 for a 13:20 flight, landing at 14:30, nothing after. */
const endsOnRide = () =>
day([stop('Bremen'), terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
legs: [seg(0), ride],
schedule: { entries: [entry('09:00'), entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
})
/** The flight first, the hotel after it. */
const opensOnRide = () =>
day([terminal('Munich Airport', 'departure', '06:00'), terminal('Hamburg Airport', 'arrival', '07:10'), stop('Hotel')], {
legs: [ride, seg(1)],
schedule: { entries: [entry('05:00', { departure: '06:00' }), entry('07:10'), entry('09:00')], warnings: [] },
})
it('FE-RTROW-052: a day that ends on a ride arrives at the arrival terminal, one that opens on a ride starts at the departure', () => {
// The head card printed the clock of the stop before the flight as where the day ends,
// while the ride block right below it printed the landing.
expect(stageClocks(roadtripRows(endsOnRide()))).toEqual({ start: '09:00', arrive: '14:30' })
expect(stageClocks(roadtripRows(opensOnRide()))).toEqual({ start: '05:00', arrive: '09:00' })
})
it('FE-RTROW-053: a day that is nothing but a ride still has both clocks', () => {
const rideOnly = day([terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
legs: [ride],
schedule: { entries: [entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
})
expect(stageClocks(roadtripRows(rideOnly))).toEqual({ start: '12:20', arrive: '14:30' })
})
it('FE-RTROW-054: up next names the departure terminal before its check-in, and the arrival after take-off', () => {
// A lone terminal (a ride landing tomorrow) was already a candidate; the terminals
// inside a same-day ride were not, so the countdown skipped the airport.
expect(upNextStop(endsOnRide(), 10 * 60, true)?.row.stop.name).toBe('Munich Airport')
expect(upNextStop(endsOnRide(), 10 * 60, true)?.minutesUntil).toBe(140)
expect(upNextStop(endsOnRide(), 13 * 60 + 30, true)?.row.stop.name).toBe('Hamburg Airport')
expect(upNextStop(endsOnRide(), 15 * 60 + 1, true)?.minutesUntil).toBe(-31)
})
it('FE-RTROW-055: a stage that ends on a ride ends at its arrival terminal', () => {
const end = stageEnd(roadtripRows(endsOnRide()))
expect(end?.stop.name).toBe('Hamburg Airport')
expect(end?.time).toBe('14:30')
expect(stageEnd(roadtripRows(opensOnRide()))?.stop.name).toBe('Hotel')
})
}) })
@@ -255,6 +255,15 @@ export function destinationCount(day: RoadtripDay): number {
return day.stops.filter(s => !s.automaticNight && !s.carrier && !isServiceStopType(s.stopType)).length return day.stops.filter(s => !s.automaticNight && !s.carrier && !isServiceStopType(s.stopType)).length
} }
/**
* The rows with each ride opened up into its two terminals, in place. A ride row holds
* them instead of two stop rows of their own, so a reader walking the rows for stops
* has to look inside it, or a day that ends on a flight ends at the stop before it.
*/
function unfoldRides(rows: readonly RoadtripRow[]): RoadtripRow[] {
return rows.flatMap(row => (row.kind === 'ride' ? [row.departure, row.arrival] : [row]))
}
/** /**
* The two clocks a stage is headed with: when it starts, and when you reach its last place. * The two clocks a stage is headed with: when it starts, and when you reach its last place.
* *
@@ -284,7 +293,7 @@ export function destinationCount(day: RoadtripDay): number {
export function stageClocks(rows: readonly RoadtripRow[]): { start: string | null; arrive: string | null } { export function stageClocks(rows: readonly RoadtripRow[]): { start: string | null; arrive: string | null } {
let start: string | null = null let start: string | null = null
let arrive: 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) { if (row.kind === 'stop' && row.time) {
start ??= row.time start ??= row.time
arrive = row.time arrive = row.time
@@ -296,7 +305,8 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
} }
/** /**
* The stop a stage ends on: the last one the chain draws, a service stop included. * The stop a stage ends on: the last one the chain draws, a service stop or the arrival
* terminal of a ride included.
* *
* The map half names it in its bar and opens it on a tap, so the name, the clock beside it * 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 * and the sheet the tap brings up all come off this one row and cannot name three different
@@ -308,8 +318,9 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
* There is nothing for a tap to open then, and the bar says so by not being a button. * 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 { export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
for (let i = rows.length - 1; i >= 0; i--) { const unfolded = unfoldRides(rows)
const row = rows[i] for (let i = unfolded.length - 1; i >= 0; i--) {
const row = unfolded[i]
if (row.kind === 'stop') return row if (row.kind === 'stop') return row
} }
return null return null
@@ -355,7 +366,7 @@ export function upNextStop(
isToday: boolean, isToday: boolean,
): { row: StopRow; minutesUntil: number } | null { ): { row: StopRow; minutesUntil: number } | null {
if (!day || !isToday) return 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) .filter((r): r is StopRow => r.kind === 'stop' && !r.service)
.map(row => ({ row, at: clockMinutes(row.time) })) .map(row => ({ row, at: clockMinutes(row.time) }))
.filter((x): x is { row: StopRow; at: number } => x.at != null) .filter((x): x is { row: StopRow; at: number } => x.at != null)
@@ -55,6 +55,23 @@ describe('dayBookings (#2428)', () => {
expect(dayBookings(day, [])).toEqual({ atStop: new Map(), loose: [] }) 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', () => { 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('restaurant')).toBe(Utensils)
expect(bookingIcon('car')).toBe(Car) expect(bookingIcon('car')).toBe(Car)
+20 -1
View File
@@ -1,6 +1,7 @@
import { Ticket, type LucideIcon } from 'lucide-react' import { Ticket, type LucideIcon } from 'lucide-react'
import { isCarrierType, isRentalType } from '@trek/shared/roadtrip' import { isCarrierType, isRentalType } from '@trek/shared/roadtrip'
import { RES_ICONS } from '../Planner/DayPlanSidebar.constants' import { RES_ICONS } from '../Planner/DayPlanSidebar.constants'
import { hidesOnMiddleDay } from '../../utils/dayMerge'
import { splitReservationDateTime } from '../../utils/formatters' import { splitReservationDateTime } from '../../utils/formatters'
import type { Reservation } from '../../types' import type { Reservation } from '../../types'
import type { RoadtripStop } from '@trek/shared/roadtrip' import type { RoadtripStop } from '@trek/shared/roadtrip'
@@ -42,9 +43,27 @@ function byClock(a: Reservation, b: Reservation): number {
return a.id - b.id 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( export function dayBookings(
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight'>[] }, day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight'>[] },
reservations: readonly Reservation[], 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 { ): DayBookings {
if (!reservations.length) return EMPTY if (!reservations.length) return EMPTY
const atStop = new Map<number, Reservation[]>() const atStop = new Map<number, Reservation[]>()
@@ -56,7 +75,7 @@ export function dayBookings(
// automatic night has no assignment anybody pinned to. // automatic night has no assignment anybody pinned to.
const real = (i: number): boolean => !stops[i]!.carrier && !stops[i]!.automaticNight 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 let index = r.assignment_id ? stops.findIndex((s, i) => real(i) && s.assignmentId === r.assignment_id) : -1
const onDay = r.day_id === day.dayId || (r.end_day_id != null && r.end_day_id === day.dayId) const onDay = spansDay(r, day.dayId, dayOrder)
if (index < 0 && onDay && r.place_id) index = stops.findIndex((s, i) => real(i) && s.placeId === r.place_id) if (index < 0 && onDay && r.place_id) index = stops.findIndex((s, i) => real(i) && s.placeId === r.place_id)
if (index >= 0) { if (index >= 0) {
const list = atStop.get(index) ?? [] const list = atStop.get(index) ?? []
@@ -283,4 +283,20 @@ describe('useCorridorPois', () => {
expect(plain.current.results.map(r => r.osm_id).sort()).toEqual(['on-the-road', 'under-the-flight']) expect(plain.current.results.map(r => r.osm_id).sort()).toEqual(['on-the-road', 'under-the-flight'])
expect(plain.current.progress.total).toBeGreaterThan(result.current.progress.total) 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo' import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo'
import { useTranslation } from '../../i18n' 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 { DESKTOP_CORRIDOR_BUDGET, type CorridorBudget } from './corridorSearchModel'
import type { Poi } from '../Map/poiCategories' import type { Poi } from '../Map/poiCategories'
@@ -93,14 +93,14 @@ export function useCorridorPois(
* there, and boxing that would search open sea, or the countryside under a flight * 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 * 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 * spine and the stretch between the two is left out of the search, and a hit that
* lands in it is dropped. * lands in it is dropped. Memoised by the caller: the array's identity is what
* recreates `search`, so a fresh one every render would recreate it every render.
*/ */
gaps?: { from: LatLng; to: LatLng }[] gaps?: { from: LatLng; to: LatLng }[]
}, },
): CorridorSearch { ): CorridorSearch {
const budget = options?.budget ?? DESKTOP_CORRIDOR_BUDGET const budget = options?.budget ?? DESKTOP_CORRIDOR_BUDGET
const gaps = options?.gaps const gaps = options?.gaps
const gapKey = (gaps ?? []).map(g => `${g.from.lat.toFixed(5)},${g.from.lng.toFixed(5)}>${g.to.lat.toFixed(5)},${g.to.lng.toFixed(5)}`).join(';')
const { locale } = useTranslation() const { locale } = useTranslation()
const [results, setResults] = useState<CorridorPoi[]>([]) const [results, setResults] = useState<CorridorPoi[]>([])
const [progress, setProgress] = useState({ done: 0, total: 0 }) const [progress, setProgress] = useState({ done: 0, total: 0 })
@@ -150,33 +150,9 @@ export function useCorridorPois(
// Only the TILE line is cut, never the spine: every `alongKm` in a result is a // 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 // 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 // spine would renumber both the moment the window moved, and a hit would land at the
// wrong position in the day's chain. // wrong position in the day's chain. The MCP corridor tool cuts the same way.
const tileLine = window ? sliceAtMeters(spine, window.fromKm * 1000, window.toKm * 1000) : spine const ridden = riddenRanges(spine, gaps ?? [])
// The ridden stretches, as kilometres along the spine, in order. const allTiles = drivenPieces(spine, ridden, window).flatMap(piece => corridorTiles(piece, widthKm))
const skipped = (gaps ?? [])
.map(gap => {
const a = projectOntoRoute(gap.from, spine)?.alongKm
const b = projectOntoRoute(gap.to, spine)?.alongKm
return a === undefined || b === undefined ? null : { fromKm: Math.min(a, b), toKm: Math.max(a, b) }
})
.filter((g): g is { fromKm: number; toKm: number } => g !== null && g.toKm > g.fromKm)
.sort((a, b) => a.fromKm - b.fromKm)
const inGap = (alongKm: number): boolean => skipped.some(g => alongKm > g.fromKm + 0.05 && alongKm < g.toKm - 0.05)
// The tile line cut around the gaps: one piece per driven stretch.
const pieces: LatLng[][] = []
if (skipped.length) {
const start = window?.fromKm ?? 0
const end = window?.toKm ?? Number.POSITIVE_INFINITY
let cursor = start
for (const gap of skipped) {
if (gap.fromKm > cursor) pieces.push(sliceAtMeters(spine, cursor * 1000, Math.min(gap.fromKm, end) * 1000))
cursor = Math.max(cursor, gap.toKm)
}
if (cursor < end) pieces.push(Number.isFinite(end) ? sliceAtMeters(spine, cursor * 1000, end * 1000) : sliceAtMeters(spine, cursor * 1000, Number.MAX_SAFE_INTEGER))
} else {
pieces.push(tileLine)
}
const allTiles = pieces.filter(piece => piece.length > 1).flatMap(piece => corridorTiles(piece, widthKm))
const tiles = allTiles.slice(0, budget.maxTiles) const tiles = allTiles.slice(0, budget.maxTiles)
const startedAt = Date.now() const startedAt = Date.now()
const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs
@@ -221,7 +197,7 @@ export function useCorridorPois(
for (const poi of data.pois) { for (const poi of data.pois) {
if (seen.has(poi.osm_id)) continue if (seen.has(poi.osm_id)) continue
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine) const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
if (!hit || hit.offRouteKm > widthKm || inGap(hit.alongKm)) continue if (!hit || hit.offRouteKm > widthKm || inRiddenRange(ridden, hit.alongKm)) continue
seen.set(poi.osm_id, { seen.set(poi.osm_id, {
...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null, ...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null,
opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null, opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null,
@@ -288,10 +264,7 @@ export function useCorridorPois(
setError(failures === jobs.length && failures > 0) setError(failures === jobs.length && failures > 0)
setLoading(false) setLoading(false)
})() })()
// gapKey stands in for `gaps`: the same terminals mean the same gaps, and a fresh }, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gaps])
// array every render would make every search a new function.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gapKey])
useEffect(() => () => abortRef.current?.abort(), []) useEffect(() => () => abortRef.current?.abort(), [])
@@ -313,4 +313,37 @@ describe('useRoadtripCorridor', () => {
expect(result.current.insertIndexFor(beforeFirstStop)).toBe(1) 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 type { CorridorBudget } from './corridorSearchModel'
import { sectionAnchors, insertIndexForAlong } from './roadtripModel' import { sectionAnchors, insertIndexForAlong } from './roadtripModel'
import type { SectionAnchor } 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 { useVehicleRange } from './useVehicleRange'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes' import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
@@ -121,19 +121,9 @@ export function useRoadtripCorridor(
// The rides on this day: between a departure terminal and its arrival the car is not // 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). // on the road, so that stretch of the line is not searched (#2428).
const gaps = useMemo(() => { const rides = useMemo(() => (day ? rideGaps(day.stops) : []), [day])
if (!day) return []
const out: { from: LatLng; to: LatLng }[] = []
day.stops.forEach((stop, i) => {
const next = day.stops[i + 1]
if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) {
out.push({ from: { lat: stop.lat, lng: stop.lng }, to: { lat: next.lat, lng: next.lng } })
}
})
return out
}, [day])
const search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps }), [options, gaps])) const search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps: rides }), [options, rides]))
/** /**
* Whether what the panel looks for has been decided yet. * Whether what the panel looks for has been decided yet.
@@ -207,8 +197,14 @@ export function useRoadtripCorridor(
* Resolved here rather than stored: `section` names an anchor, and its kilometre figure * 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 looked up fresh, so changing the corridor width does not move the point the list
* is narrowed around. * 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(() => { const anchorKm = useMemo(() => {
if (!section || !day || section.dayId !== day.dayId) return null if (!section || !day || section.dayId !== day.dayId) return null
return anchors.find(a => a.kind === section.kind && a.index === section.index)?.alongKm ?? null return anchors.find(a => a.kind === section.kind && a.index === section.index)?.alongKm ?? null
@@ -931,6 +931,49 @@ describe('a booking the traveller rides (#2428)', () => {
expect(result.current.days[1].arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600, distance: 0 }) 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 () => { 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)) calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }] const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
@@ -1,5 +1,5 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, seatCarrierStops, standsAsDay, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip' import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, isCarrierMode, seatCarrierStops, standsAsDay, terminalAssignmentId, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator' import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
import { resolveLegMode } from '../Planner/legMode' import { resolveLegMode } from '../Planner/legMode'
@@ -248,6 +248,31 @@ export function useRoadtripRoutes(
/** Days apart, for a ride that lands on a later day than it left. */ /** 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 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 // Only the geometry decides whether legs have to be re-fetched: renaming a place or
// editing its notes must not fire a routing round. // editing its notes must not fire a routing round.
const viaKey = useMemo( const viaKey = useMemo(
@@ -305,13 +330,15 @@ export function useRoadtripRoutes(
* into a burst at the one host that refused it. * into a burst at the one host that refused it.
*/ */
const enqueueRun = (day: PlanDay, dayLegs: Record<string, RoutedLeg>, run: RoadtripStop[], mode: string): void => { const enqueueRun = (day: PlanDay, dayLegs: Record<string, RoutedLeg>, run: RoadtripStop[], mode: string): void => {
// A ride is no road: the leg between its terminals is the booking's minutes, and // A ride is no road, so no router is asked. The two ends of a booked ride have
// no router is asked. Still a task of its own, so it is published with the rest // their leg in `rideLegs`, which keeps step with an edited timetable; a terminal
// and a day that is nothing but a ride is not left waiting for a round that never // standing beside an ordinary stop is the other carrier-mode pair, and it still
// comes. // needs a leg of its own. Without one `computeSchedule` loses its cursor there and
const rides = carrierLegsFor(run, mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey) // every arrival behind it goes blank. A run is one stored day, so its ends are
if (rides) { // never days apart.
tasks.push(async () => { Object.assign(dayLegs, rides) }) 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 return
} }
/** The same stops, as one request per pair. A pair has nothing left to split. */ /** The same stops, as one request per pair. A pair has nothing left to split. */
@@ -416,22 +443,29 @@ export function useRoadtripRoutes(
// routes a single day; a road trip is every leg of every day at once, and the // 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 // 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. // published as they land so the rail fills in instead of sitting empty.
const publish = (): void => {
setLegsByDay({ ...collected })
setSnapByDay({ ...collectedSnaps })
setMissedByDay({ ...collectedMisses })
}
void (async () => { void (async () => {
for (let i = 0; i < tasks.length; i++) { for (let i = 0; i < tasks.length; i++) {
if (controller.signal.aborted) return if (controller.signal.aborted) return
const startedAt = performance.now() const startedAt = performance.now()
await tasks[i]() await tasks[i]()
if (controller.signal.aborted) return if (controller.signal.aborted) return
setLegsByDay({ ...collected }) publish()
setSnapByDay({ ...collectedSnaps })
setMissedByDay({ ...collectedMisses })
// Only pace what actually went out. RouteCalculator answers a repeat from its // 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 // 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. // all repeats — waiting a second between those made a warm view feel broken.
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, controller.signal) 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() return () => controller.abort()
@@ -455,9 +489,10 @@ export function useRoadtripRoutes(
// every routing round while `seamLegs` only ever grows, so a pair that used to sit on // 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 // 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 // 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. // exist the day run is the newer of the two, so it is the one to believe. The rides
return { ...seamLegs, ...out } // last: a ride is never a road, whatever was fetched for its pair.
}, [plan, legsByDay, seamLegs]) return { ...seamLegs, ...out, ...rideLegs }
}, [plan, legsByDay, seamLegs, rideLegs])
// Which date each stop is actually reached on. Nothing is written to make it so; see // Which date each stop is actually reached on. Nothing is written to make it so; see
// `nightSpill.ts`. // `nightSpill.ts`.
@@ -476,6 +511,8 @@ export function useRoadtripRoutes(
const seams = useMemo(() => { const seams = useMemo(() => {
const out: { from: RoadtripStop; to: RoadtripStop; dayId: number }[] = [] const out: { from: RoadtripStop; to: RoadtripStop; dayId: number }[] = []
const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => { 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 // 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. // change, so there is nothing to catch up here.
if (legsByDay[from.ownerDayId]?.[legKey(from, to)]) return if (legsByDay[from.ownerDayId]?.[legKey(from, to)]) return
@@ -509,7 +546,7 @@ export function useRoadtripRoutes(
} }
} }
return out 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(';') 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. */ /** When the last seam request went out, across every run of the effect below. */
const lastSeamRequestAt = useRef(0) const lastSeamRequestAt = useRef(0)
@@ -520,6 +557,22 @@ export function useRoadtripRoutes(
void (async () => { void (async () => {
for (const seam of seams) { for (const seam of seams) {
if (controller.signal.aborted) return 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. // Paced before the request, against a clock that outlives this effect.
// //
// Waiting AFTER one instead spaced nothing: storing a seam changes // Waiting AFTER one instead spaced nothing: storing a seam changes
@@ -534,19 +587,6 @@ export function useRoadtripRoutes(
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, controller.signal) if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, controller.signal)
if (controller.signal.aborted) return if (controller.signal.aborted) return
lastSeamRequestAt.current = performance.now() lastSeamRequestAt.current = performance.now()
const mode = resolveLegMode(
{ isPlace: true, leg_transport_mode: seam.from.legMode },
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
)
// A ride that lands on a later day is the one seam that is never a road: its
// terminals are on two cards, and the leg between them is the booking's minutes.
const ride = carrierLegsFor([seam.from, seam.to], mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey)
if (ride) {
const key = legKey(seam.from, seam.to)
setSeamLegs(prev => ({ ...prev, [key]: { ...ride[key], shape: seamShape(seam.from, viasByDay) } }))
continue
}
try { try {
// The via points on this seam, threaded in the same way the day runs thread // 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 // theirs. Without them a seam is the one stretch of the trip a via cannot
@@ -644,11 +644,11 @@ describe('IntegrationsTab', () => {
expect(createBtn).toBeDisabled(); 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(); const user = userEvent.setup();
server.use( server.use(
http.post('/api/oauth/clients', () => 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(); enableMcp();
@@ -657,9 +657,24 @@ describe('IntegrationsTab', () => {
await user.click(screen.getByRole('button', { name: /New Client/i })); await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client'); await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail 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.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
await user.click(screen.getByRole('button', { name: /Register Client/i })); 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 { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker' import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { getApiErrorMessage } from '../../utils/apiError'
interface OAuthPreset { interface OAuthPreset {
id: string id: string
@@ -266,8 +267,11 @@ function useIntegrations() {
setOauthNewUris('') setOauthNewUris('')
setOauthNewScopes([]) setOauthNewScopes([])
setOauthIsMachine(false) setOauthIsMachine(false)
} catch { } catch (err) {
toast.error(t('settings.oauth.toast.createError')) // 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 { } finally {
setOauthCreating(false) 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 { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render' import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event' 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. // The places card keeps the pill: that one is about the index.
expect(cards[0].querySelector('.trek-mark .pill-bg')).not.toBeNull() 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} aria-describedby={bodyId}
className="rn-panel" 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 ─────────────────────────────────────────── */} {/* ── Left: the release ─────────────────────────────────────────── */}
<div className="rn-release"> <div className="rn-release">
<div className="rn-release-grain" aria-hidden="true" /> <div className="rn-release-grain" aria-hidden="true" />
@@ -127,12 +135,6 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
{/* ── Right: the note ───────────────────────────────────────────── */} {/* ── Right: the note ───────────────────────────────────────────── */}
<div className="rn-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-body">
<div className="rn-note-eyebrow">{t(release.note.eyebrowKey)}</div> <div className="rn-note-eyebrow">{t(release.note.eyebrowKey)}</div>
<h3 className="rn-note-title">{t(release.note.titleKey)}</h3> <h3 className="rn-note-title">{t(release.note.titleKey)}</h3>
@@ -50,6 +50,7 @@
.rn-close { box-sizing: content-box; } .rn-close { box-sizing: content-box; }
.rn-panel { .rn-panel {
position: relative;
width: min(1420px, calc(100% - 2px)); width: min(1420px, calc(100% - 2px));
max-height: 100%; max-height: 100%;
display: grid; display: grid;
@@ -67,12 +68,60 @@
line-height: normal; line-height: normal;
} }
/* The panel's, not the note's: in the stacked layout below it has to outlast the
release half, which scrolls by first. */
.rn-close {
position: absolute;
top: 22px;
/* Logical, so the X sits at the reading end in Arabic too. With the physical
right it stayed over the release half there, which the note's palette below
is not made for, and the stacked layout put it at the other end anyway. */
inset-inline-end: 22px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-hover);
border: 1px solid var(--border-faint);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
z-index: 2;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.rn-close:hover { background: var(--bg-selected); color: var(--text-primary); }
/* Below the two-column breakpoint the halves stack and the panel scrolls. The /* 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 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. */ the whole panel and the release half was left with nothing but its padding. */
@media (max-width: 1080px) { @media (max-width: 1080px) {
.rn-panel { grid-template-columns: 1fr; grid-template-rows: auto; overflow-y: auto; } .rn-panel { grid-template-columns: 1fr; grid-template-rows: auto auto; overflow-y: auto; }
.rn-release { grid-area: 1 / 1; }
.rn-note { grid-area: 2 / 1; }
.rn-release, .rn-release-inner, .rn-note-body { overflow: visible; } .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 /* 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. */ band's usual tint is translucent, so it gets a solid one here. */
.rn-support { .rn-support {
@@ -560,26 +609,6 @@
min-height: 0; 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 { .rn-note-body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -12,10 +12,108 @@ describe('release notice css', () => {
const notice = read('src/components/SystemNotices/releaseNotice.css') const notice = read('src/components/SystemNotices/releaseNotice.css')
const tokens = read('src/index.css') const tokens = read('src/index.css')
const block = (selector: string): string => { const within = (source: string, selector: string): string => {
const at = notice.indexOf(selector) const at = source.indexOf(selector)
expect(at, `${selector} missing from releaseNotice.css`).toBeGreaterThan(-1) 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 => { const step = (name: string): number => {
@@ -47,4 +145,60 @@ describe('release notice css', () => {
expect(columns).toMatch(/flex-shrink:\s*0/) expect(columns).toMatch(/flex-shrink:\s*0/)
expect(block('.rn-promise {')).toMatch(/overflow:\s*hidden/) 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)
}
}
}
})
}) })
+6 -1
View File
@@ -1501,8 +1501,13 @@ img[alt="TREK"] {
} }
.collab-link-chip__action:hover, .collab-link-chip__action:focus-visible { background: var(--bg-hover); color: var(--text-primary); } .collab-link-chip__action:hover, .collab-link-chip__action:focus-visible { background: var(--bg-hover); color: var(--text-primary); }
.collab-link-chip__action--on { color: var(--accent); } .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) { @media (hover: none) {
.collab-link-chip__actions { opacity: 1; } .collab-link-chip__actions { opacity: 1; gap: 4px; }
.collab-link-chip__action { width: 30px; height: 30px; margin: -4px 0; }
} }
/* Markdown in Collab Notes */ /* Markdown in Collab Notes */
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { AlertTriangle, ChevronDown, RefreshCw } from 'lucide-react' import { AlertTriangle, ChevronDown, RefreshCw } from 'lucide-react'
import { adminApi, authApi } from '../../../api/client' import { adminApi, authApi } from '../../../api/client'
import { getApiErrorMessage } from '../../../types' import { getApiErrorMessage } from '../../../types'
import { placesGoogleOnlyHint } from '../../../utils/placeSource'
import type { TranslationFn } from '../../../types' import type { TranslationFn } from '../../../types'
import type { useAdmin } from '../../../pages/admin/useAdmin' import type { useAdmin } from '../../../pages/admin/useAdmin'
import MToggle from '../../components/MToggle' import MToggle from '../../components/MToggle'
@@ -353,7 +354,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
/> />
<MAdminRow <MAdminRow
title={t('admin.placesGoogleOnly.title')} title={t('admin.placesGoogleOnly.title')}
hint={t(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')} hint={t(placesGoogleOnlyHint(hasMapsKey, placesProvider))}
trailing={ trailing={
<MToggle <MToggle
checked={placesGoogleOnly} checked={placesGoogleOnly}
@@ -7,7 +7,7 @@ import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog' import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
import ConfirmDialog from '../../../components/shared/ConfirmDialog' import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker' 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 { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore' import { useJourneyStore } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore' import { useAddonStore } from '../../../store/addonStore'
@@ -388,7 +388,7 @@ export default function MJourneyDetail() {
onClick={() => openLightbox(gallery, i)} onClick={() => openLightbox(gallery, i)}
className="relative aspect-square overflow-hidden rounded-[14px]" 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)]" /> <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" /> <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 { useEffect, useMemo, useRef, useState } from 'react'
import { localIsoDate } from '../../../utils/localDate' 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 MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn' import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle' import MToggle from '../../components/MToggle'
@@ -16,7 +16,7 @@ import { getCurrentPositionOnce } from '../../../hooks/useGeolocation'
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue' import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore' import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore' 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 JournalBody from '../../../components/Journey/JournalBody'
import { ProviderPicker, type ProviderPhotoGroup } from '../../../components/Journey/JourneyDetailPageProviderPicker' import { ProviderPicker, type ProviderPhotoGroup } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { journeyWeatherCategory, MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta' import { journeyWeatherCategory, MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
@@ -33,6 +33,18 @@ interface LocationResult {
type PendingProviderGroup = ProviderPhotoGroup & { provider: string } 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 { interface MJourneyEntrySheetProps {
entry: JourneyEntry entry: JourneyEntry
galleryPhotos: GalleryPhoto[] 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> </button>
))} ))}
{availableGalleryPhotos.length === 0 && ( {availableGalleryPhotos.length === 0 && (
@@ -610,7 +626,11 @@ export default function MJourneyEntrySheet({
<div className="mt-[10px] flex flex-wrap gap-2"> <div className="mt-[10px] flex flex-wrap gap-2">
{photos.map((p, idx) => ( {photos.map((p, idx) => (
<div key={p.id} className="relative h-16 w-16 overflow-hidden rounded-[13px]"> <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 && ( {!readOnly && idx > 0 && photos.length > 1 && (
<button <button
type="button" type="button"
@@ -11,6 +11,7 @@ import MChip from '../../components/MChip'
import { MSetCard, MSetEyebrow, MSetInput, MSetTextarea, MSetButton, MSetHint, MSetRow } from './MSettingsUi' import { MSetCard, MSetEyebrow, MSetInput, MSetTextarea, MSetButton, MSetHint, MSetRow } from './MSettingsUi'
import MConfirmSheet from './MConfirmSheet' import MConfirmSheet from './MConfirmSheet'
import MSegmented from '../../components/MSegmented' import MSegmented from '../../components/MSegmented'
import { getApiErrorMessage } from '../../../utils/apiError'
interface OAuthPreset { interface OAuthPreset {
id: string id: string
@@ -188,8 +189,11 @@ export default function MSettingsMcp() {
setNewUris('') setNewUris('')
setNewScopes([]) setNewScopes([])
setIsMachine(false) setIsMachine(false)
} catch { } catch (err) {
toast.error(t('settings.oauth.toast.createError')) // 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 { } finally {
setCreating(false) setCreating(false)
} }
@@ -381,7 +381,11 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
dayOrderMap={planner.dayOrderMap} dayOrderMap={planner.dayOrderMap}
reservations={planner.reservations} reservations={planner.reservations}
showReservationStats={true} 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 // Transport overlay tap → the mobile transport detail sheet (desktop
// routes this through mapTransportDetail into the day sidebar instead). // routes this through mapTransportDetail into the day sidebar instead).
onReservationClick={(rid: number) => shell.openSheet('transport', { reservationId: rid })} 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 { t, trip, can } = planner
const canEdit = can('day_edit', trip) const canEdit = can('day_edit', trip)
const editing = shell.mode === 'edit' && canEdit 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. // Per-segment travel mode (#1281): tap a connector → pick the leg's mode.
const legMenu = useContextMenu() const legMenu = useContextMenu()
const modeIcon = (key: string) => (key === 'walking' ? Footprints : key.startsWith('plugin:') ? Zap : Car) 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)} reorder={reorderFor(row.item)}
drag={dragFor(row)} drag={dragFor(row)}
onOpen={() => openPlace(row.assignment)} onOpen={() => openPlace(row.assignment)}
onEdit={() => tl.editAssignment(row.assignment)} onEdit={canEditPlaces ? () => tl.editAssignment(row.assignment) : undefined}
onRemove={() => tl.removeAssignment(row.assignment)} onRemove={() => tl.removeAssignment(row.assignment)}
/> />
{dayScheduleFor('assignment', row.assignment.id)} {dayScheduleFor('assignment', row.assignment.id)}
@@ -124,7 +124,7 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
reorder: ReactNode reorder: ReactNode
drag?: RowDrag drag?: RowDrag
onOpen: () => void onOpen: () => void
onEdit: () => void onEdit?: () => void
onRemove: () => void onRemove: () => void
}) { }) {
const { t } = chrome const { t } = chrome
@@ -210,9 +210,11 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
</div> </div>
{chrome.editing && ( {chrome.editing && (
<span className="flex flex-none items-center gap-1.5"> <span className="flex flex-none items-center gap-1.5">
<ActionCircle label={t('common.edit')} onClick={onEdit}> {onEdit && (
<Pencil size={14} strokeWidth={2} /> <ActionCircle label={t('common.edit')} onClick={onEdit}>
</ActionCircle> <Pencil size={14} strokeWidth={2} />
</ActionCircle>
)}
<ActionCircle label={t('planner.removeFromDay')} onClick={onRemove}> <ActionCircle label={t('planner.removeFromDay')} onClick={onRemove}>
<X size={14} strokeWidth={2} /> <X size={14} strokeWidth={2} />
</ActionCircle> </ActionCircle>
@@ -3,7 +3,7 @@ import type { CSSProperties, ReactNode } from 'react'
import MDancingTrek from '../../../components/MDancingTrek' import MDancingTrek from '../../../components/MDancingTrek'
import MIconBtn from '../../../components/MIconBtn' import MIconBtn from '../../../components/MIconBtn'
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel' import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
import { carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide' import { bookingOpens, carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
import { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings' import { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings'
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds' import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
import { formatDistance } from '../../../../utils/units' import { formatDistance } from '../../../../utils/units'
@@ -252,12 +252,15 @@ export function RtRideRow({ row, chrome, onOpen }: {
/** /**
* The bookings a stop carries, under its row (#2428): the table, the tickets, the tour, * 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 * each a chip with the booking panel's icon, its name and the clock it starts at. Chips
* because a booking is a fact about the stop and not a stop of its own. Each opens its * because a booking is a fact about the stop and not a stop of its own. A chip that
* booking; without `onOpen` they are plain. * opens is a button; one this reader may not open stays a plain chip, the rule the rail
* follows too (`bookingOpens`).
*/ */
export function RtBookingChips({ bookings, chrome, onOpen }: { export function RtBookingChips({ bookings, chrome, canEdit, onOpen }: {
bookings: Reservation[] bookings: Reservation[]
chrome: RowChrome chrome: RowChrome
/** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
canEdit: boolean
onOpen?: (reservation: Reservation) => void onOpen?: (reservation: Reservation) => void
}) { }) {
return ( return (
@@ -275,7 +278,7 @@ export function RtBookingChips({ bookings, chrome, onOpen }: {
</> </>
) )
const cls = 'inline-flex h-[28px] max-w-full items-center gap-[5px] rounded-full border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-[10px]' const cls = 'inline-flex h-[28px] max-w-full items-center gap-[5px] rounded-full border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-[10px]'
return onOpen ? ( return onOpen && bookingOpens(r, canEdit) ? (
<button key={r.id} type="button" onClick={() => onOpen(r)} className={`${cls} text-start`}>{body}</button> <button key={r.id} type="button" onClick={() => onOpen(r)} className={`${cls} text-start`}>{body}</button>
) : ( ) : (
<span key={r.id} className={cls}>{body}</span> <span key={r.id} className={cls}>{body}</span>
@@ -1,4 +1,4 @@
import { Fragment, useMemo, useRef } from 'react' import { Fragment, useCallback, useMemo, useRef } from 'react'
import { AlertTriangle, MapPin, Navigation } from 'lucide-react' import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe' import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
import { showStopOnMap, useMRoadtrip } from './useMRoadtrip' import { showStopOnMap, useMRoadtrip } from './useMRoadtrip'
@@ -18,6 +18,7 @@ import { isRtlLanguage } from '../../../../i18n'
import type { MTripTabPanelProps } from '../MTripShell' import type { MTripTabPanelProps } from '../MTripShell'
import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel' import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import { dayBookings } from '../../../../components/Roadtrip/stopBookings' import { dayBookings } from '../../../../components/Roadtrip/stopBookings'
import { getDayOrder } from '../../../../utils/dayOrder'
import type { Reservation } from '../../../../types' import type { Reservation } from '../../../../types'
/** /**
@@ -84,22 +85,30 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
else shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId }) 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 // 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 // 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 // sheet opens its linked bookings: a transport in its sheet, anything else in its
// editor, and only for somebody allowed to. // editor.
const bookings = useMemo(() => { const bookings = useMemo(() => {
const byIndex = stage ? dayBookings(stage, planner.reservations) : null const byIndex = stage ? dayBookings(stage, planner.reservations, dayOrder) : null
const atStop = new Map<number, Reservation[]>() const atStop = new Map<number, Reservation[]>()
if (stage && byIndex) for (const [i, list] of byIndex.atStop) atStop.set(stage.stops[i]!.assignmentId, list) if (stage && byIndex) for (const [i, list] of byIndex.atStop) atStop.set(stage.stops[i]!.assignmentId, list)
return { atStop, loose: byIndex?.loose ?? [] } return { atStop, loose: byIndex?.loose ?? [] }
}, [stage, planner.reservations]) }, [stage, planner.reservations, dayOrder])
// A chip that may not be opened is not a button in the first place (`bookingOpens`),
// so nothing is turned away silently here.
const canEditBookings = planner.can('reservation_edit', planner.trip)
const openBooking = (res: Reservation) => { const openBooking = (res: Reservation) => {
if (planner.TRANSPORT_TYPES.has(res.type)) { if (planner.TRANSPORT_TYPES.has(res.type)) {
shell.openSheet('transport', { reservationId: res.id }) shell.openSheet('transport', { reservationId: res.id })
return return
} }
if (!planner.can('reservation_edit', planner.trip)) return
planner.setEditingReservation(res) planner.setEditingReservation(res)
planner.setShowReservationModal(true) planner.setShowReservationModal(true)
} }
@@ -263,7 +272,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
}) })
: undefined} : undefined}
/> />
{chips && <RtBookingChips bookings={chips} chrome={chrome} onOpen={openBooking} />} {chips && <RtBookingChips bookings={chips} chrome={chrome} canEdit={canEditBookings} onOpen={openBooking} />}
</Fragment> </Fragment>
) )
} }
@@ -315,7 +324,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
<span aria-hidden="true" /> <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> <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> </div>
<RtBookingChips bookings={bookings.loose} chrome={chrome} onOpen={openBooking} /> <RtBookingChips bookings={bookings.loose} chrome={chrome} canEdit={canEditBookings} onOpen={openBooking} />
</div> </div>
)} )}
</section> </section>
@@ -4,6 +4,7 @@ import MSheet from '../../../components/MSheet'
import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeModal' import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeModal'
import { useTripStore } from '../../../../store/tripStore' import { useTripStore } from '../../../../store/tripStore'
import { useSettingsStore } from '../../../../store/settingsStore' import { useSettingsStore } from '../../../../store/settingsStore'
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import { INNER_CLS, TileHeader } from './MTripSheetUi' import { INNER_CLS, TileHeader } from './MTripSheetUi'
import type { MTripSheetsProps } from '../MTripShell' 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). // The PDF is built outside React, so it cannot read this itself (#2066).
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) 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 open = shell.sheet?.id === 'export'
const dayNotes = useTripStore(s => s.dayNotes) const dayNotes = useTripStore(s => s.dayNotes)
const [subscribeOpen, setSubscribeOpen] = useState(false) const [subscribeOpen, setSubscribeOpen] = useState(false)
@@ -44,7 +48,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
trip: planner.trip, trip: planner.trip,
days: planner.days, days: planner.days,
places: planner.places, places: planner.places,
assignments: planner.assignments, assignments: planner.storedAssignments,
categories: planner.categories, categories: planner.categories,
dayNotes: flatNotes, dayNotes: flatNotes,
reservations: planner.reservations, reservations: planner.reservations,
@@ -52,6 +56,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
locale, locale,
timeFormat, timeFormat,
distanceUnit, distanceUnit,
showServiceStops,
}) })
} catch (e) { } catch (e) {
planner.toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`) planner.toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`)
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, RotateCcw, Search } from 'lucide-react' import { Loader2, RotateCcw, Search } from 'lucide-react'
import { mapsApi } from '../../../../api/client' import { mapsApi } from '../../../../api/client'
import { useAuthStore } from '../../../../store/authStore' import { useAuthStore } from '../../../../store/authStore'
import { offersGoogleRetry, sourceLabelFor } from '../../../../utils/placeSource' import { offersGoogleRetry, selectGoogleHoldsSlot, sourceLabelFor } from '../../../../utils/placeSource'
import { recordPlacePick } from '../../../../api/placeShadow' import { recordPlacePick } from '../../../../api/placeShadow'
import { PlacesSession } from '../../../../utils/placesSession' import { PlacesSession } from '../../../../utils/placesSession'
import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers' import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
@@ -98,7 +98,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
const [acSource, setAcSource] = useState('') const [acSource, setAcSource] = useState('')
// What answered the last full search, for the line that offers Google instead. // What answered the last full search, for the line that offers Google instead.
const [searchSource, setSearchSource] = useState('') const [searchSource, setSearchSource] = useState('')
const hasMapsKey = useAuthStore(s => s.hasMapsKey) const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const setResolving = useCallback( const setResolving = useCallback(
(v: boolean) => { (v: boolean) => {
@@ -173,7 +173,9 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
} }
const handleSearch = async (provider?: 'google') => { const handleSearch = async (provider?: 'google') => {
const trimmed = query.trim() // The retry sends the query the list came from, as the desktop form does:
// the list stays on screen while the field is edited or cleared.
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : query.trim()
if (!trimmed) return if (!trimmed) return
setSuggestions([]) setSuggestions([])
@@ -187,7 +189,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
setResolving(true) setResolving(true)
try { try {
if (isMapUrl(trimmed)) { if (!provider && isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed) const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) { if (resolved.lat && resolved.lng) {
onPick({ onPick({
@@ -339,8 +341,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
)} )}
{/* The same quiet line the desktop form has: the index answers first and {/* 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 Google only when it finds nothing, so this is how a list with the wrong
place on it reaches Google. */} place on it reaches Google, where Google holds the key slot. */}
{results.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && ( {results.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
<button <button
type="button" type="button"
onClick={() => handleSearch('google')} onClick={() => handleSearch('google')}
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types' import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet' 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 { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel' import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from './bookingsModel' import { BOOKING_TYPE_COLOR } from './bookingsModel'
@@ -261,6 +261,7 @@ function BookingCard({ res, planner, canEdit, compact }: {
</button> </button>
</div> </div>
)} )}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet <MConfirmSheet
open={confirmDelete} open={confirmDelete}
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types' import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet' 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 { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { import {
TRANSPORT_TYPE_COLOR, TRANSPORT_TYPE_COLOR,
@@ -279,6 +279,7 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
</button> </button>
</div> </div>
)} )}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet <MConfirmSheet
open={confirmDelete} open={confirmDelete}
@@ -3,6 +3,8 @@ import { ChevronDown, ChevronUp } from 'lucide-react'
import type { ReservationTraveler } from '@trek/shared' import type { ReservationTraveler } from '@trek/shared'
import { avatarSrc } from '../../../../utils/avatarSrc' import { avatarSrc } from '../../../../utils/avatarSrc'
import GuestBadge from '../../../../components/shared/GuestBadge' 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, * 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 }} /> 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). * Read-only avatar-chip cluster for a reservation's assigned travelers (#1517).
* The mobile transport / booking cards only display them — assignment editing * The mobile transport / booking cards only display them — assignment editing
+39 -1
View File
@@ -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 { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react'; import React from 'react';
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render'; 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 || '')); const badge = Array.from(shibuya.querySelectorAll('span')).find(s => /^\d+$/.test(s.textContent || ''));
expect(badge?.textContent).toBe('1'); 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();
});
}); });
+68 -40
View File
@@ -31,6 +31,7 @@ import EmptyState from '../components/shared/EmptyState';
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker'; import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
import { useTranslation } from '../i18n'; import { useTranslation } from '../i18n';
import { formatLocationName } from '../utils/formatters'; import { formatLocationName } from '../utils/formatters';
import { posterlessVideo } from './journeyDetail/JourneyDetailPage.helpers';
import { useJourneyPublic } from './journeyPublic/useJourneyPublic'; import { useJourneyPublic } from './journeyPublic/useJourneyPublic';
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = { 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}`; 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 } { function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00'); const date = new Date(d + 'T00:00:00');
return { return {
@@ -251,12 +259,14 @@ export default function JourneyPublicPage() {
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })} onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
> >
<div className={`relative h-64 w-full ${photos[0].media_type === 'video' ? 'bg-black' : ''}`}> <div className={`relative h-64 w-full ${photos[0].media_type === 'video' ? 'bg-black' : ''}`}>
<img {!posterlessVideo(photos[0]) && (
src={photoUrl(photos[0], token!, photos[0].media_type === 'video' ? 'thumbnail' : 'original')} <img
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover' 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="" }`}
/> alt=""
/>
)}
{photos[0].media_type === 'video' && ( {photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <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 })} onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
> >
<img {!posterlessVideo(p) && (
src={photoUrl(p, token!, 'thumbnail')} <img
alt="" src={photoUrl(p, token!, 'thumbnail')}
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover' alt=""
}`} className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
/> }`}
/>
)}
{p.media_type === 'video' && ( {p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <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" className="min-w-0 flex-1 cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })} onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
> >
<img {posterlessVideo(photos[0]) ? (
src={photoUrl(photos[0], token!, 'thumbnail')} <ClipTile />
alt="" ) : (
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain bg-black' : 'object-cover' <img
}`} src={photoUrl(photos[0], token!, 'thumbnail')}
/> alt=""
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
)}
</button> </button>
<div className="flex min-w-0 flex-1 flex-col" style={{ gap: 2 }}> <div className="flex min-w-0 flex-1 flex-col" style={{ gap: 2 }}>
<button <button
@@ -340,24 +356,32 @@ export default function JourneyPublicPage() {
className="min-h-0 flex-1 cursor-pointer" className="min-h-0 flex-1 cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })} onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}
> >
<img {posterlessVideo(photos[1]) ? (
src={photoUrl(photos[1], token!, 'thumbnail')} <ClipTile />
alt="" ) : (
className={`h-full w-full ${photos[1].media_type === 'video' ? 'object-contain bg-black' : 'object-cover' <img
}`} src={photoUrl(photos[1], token!, 'thumbnail')}
/> alt=""
className={`h-full w-full ${photos[1].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
)}
</button> </button>
<button <button
type="button" type="button"
className="relative min-h-0 flex-1 cursor-pointer" className="relative min-h-0 flex-1 cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })} onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}
> >
<img {posterlessVideo(photos[2]) ? (
src={photoUrl(photos[2], token!, 'thumbnail')} <ClipTile />
alt="" ) : (
className={`h-full w-full ${photos[2].media_type === 'video' ? 'object-contain bg-black' : 'object-cover' <img
}`} src={photoUrl(photos[2], token!, 'thumbnail')}
/> alt=""
className={`h-full w-full ${photos[2].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
)}
{photos.length > 3 && ( {photos.length > 3 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40"> <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"> <span className="flex items-center gap-1 text-[13px] font-semibold text-white">
@@ -529,15 +553,19 @@ export default function JourneyPublicPage() {
}) })
} }
> >
<img {posterlessVideo(photo) ? (
src={photoUrl(photo, token!, 'thumbnail')} <ClipTile />
className={`h-full w-full transition-transform hover:scale-105 ${photo.media_type === 'video' ) : (
? 'object-contain bg-black' <img
: 'object-cover' src={photoUrl(photo, token!, 'thumbnail')}
}`} className={`h-full w-full transition-transform hover:scale-105 ${photo.media_type === 'video'
alt="" ? 'object-contain bg-black'
loading="lazy" : 'object-cover'
/> }`}
alt=""
loading="lazy"
/>
)}
{photo.media_type === 'video' && ( {photo.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <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"> <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')} dark={document.documentElement.classList.contains('dark')}
readOnly readOnly
onEntryClick={(entry) => setViewingEntry(entry as any)} 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)" carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
cartoApiKey={cartoApiKey} cartoApiKey={cartoApiKey}
showMood={journey.show_mood !== 0} showMood={journey.show_mood !== 0}
@@ -269,8 +269,11 @@ describe('JourneyPublicPage wiring', () => {
const { hook } = setup({ isMobile: true, desktopTwoColumn: false }); const { hook } = setup({ isMobile: true, desktopTwoColumn: false });
expect(screen.getByTestId('mobile-timeline')).toBeInTheDocument(); 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; 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 })); (mocks.captured.mobileTimeline.onEntryClick as (e: PublicEntry) => void)(buildEntry({ id: 9 }));
expect(hook.setViewingEntry).toHaveBeenCalledWith(expect.objectContaining({ id: 9 })); expect(hook.setViewingEntry).toHaveBeenCalledWith(expect.objectContaining({ id: 9 }));
+47 -8
View File
@@ -16,7 +16,9 @@ vi.mock('react-leaflet', () => ({
), ),
TileLayer: ({ url }: { url: string }) => <div data-testid="raster-tiles" data-url={url} />, TileLayer: ({ url }: { url: string }) => <div data-testid="raster-tiles" data-url={url} />,
Marker: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>, 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>, Tooltip: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
useMap: () => ({ useMap: () => ({
fitBounds: vi.fn(), 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(() => { beforeEach(() => {
// SharedTripPage does NOT require authentication — do NOT seed auth store // SharedTripPage does NOT require authentication — do NOT seed auth store
resetAllStores(); resetAllStores();
@@ -818,10 +824,7 @@ describe('SharedTripPage', () => {
const orsay = { id: 202, name: 'Orsay', lat: 48.85, lng: 2.32, category: null }; 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 }; 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. beforeEach(() => divIconMock().mockClear());
const iconHtml = () => (L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: any[]) => String(c[0].html));
beforeEach(() => (L.divIcon as unknown as ReturnType<typeof vi.fn>).mockClear());
it('numbers the stops by order_index, not by payload order', async () => { it('numbers the stops by order_index, not by payload order', async () => {
await open('order-map-token', payload({ 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 // 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. // trip-wide pool, so reading only the nested shape painted every marker indigo.
it('reads the flat category_color the trip pool sends', async () => { 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({ await open('flatcat-token', payload({
days: [], days: [],
places: [{ id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category_color: '#ff8800', category_icon: 'landmark' }], places: [{ id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category_color: '#ff8800', category_icon: 'landmark' }],
assignments: {}, assignments: {},
})); }));
await waitFor(() => expect((L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(0)); await waitFor(() => expect(iconHtml().length).toBeGreaterThan(0));
const html = (L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: any[]) => String(c[0].html)); const html = iconHtml();
expect(html.some((h: string) => h.includes('#ff8800'))).toBe(true); expect(html.some((h: string) => h.includes('#ff8800'))).toBe(true);
expect(html.some((h: string) => h.includes('#6366f1'))).toBe(false); 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. // The booked night is not: it is already on the day as its own chip.
expect(screen.queryByText('Unter den Linden 77')).toBeNull(); 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 ───────────────────────────────── // ── #2320: the richer read-only detail ─────────────────────────────────
+22 -11
View File
@@ -167,22 +167,33 @@ export default function SharedTripPage() {
// trip-wide pool has none (it arrives by created_at). The index runs over the full // 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 // 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. // 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 const dayAssignments = selectedDay
? [...(assignments[String(selectedDay)] || [])].sort((a: any, b: any) => a.order_index - b.order_index) ? [...(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[]> = {}; const dayOrderMap: Record<number, number[]> = {};
dayAssignments.forEach((a: any, i: number) => { dayStops.forEach((a, i) => {
if (!a.place?.id) return; if (!a.place?.id) return;
(dayOrderMap[a.place.id] ||= []).push(i + 1); (dayOrderMap[a.place.id] ||= []).push(i + 1);
}); });
const dayPlaces: any[] = []; // The places these assignments sit on, in their order, each drawn once.
const seenPlaceIds = new Set<number>(); const locatedPlaces = (list: typeof dayAssignments) => {
for (const a of dayAssignments as any[]) { const seen = new Set<number>();
const p = a.place; return list.map(a => a.place).filter(p => {
if (!p?.lat || !p?.lng || seenPlaceIds.has(p.id)) continue; if (!p?.lat || !p?.lng || seen.has(p.id)) return false;
seenPlaceIds.add(p.id); seen.add(p.id);
dayPlaces.push(p); return true;
} });
};
const dayPlaces = locatedPlaces(dayAssignments);
const dayLine = locatedPlaces(dayStops);
const mapPlaces = selectedDay ? dayPlaces : (places || []).filter((p: any) => p?.lat && p?.lng); 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 // 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} /> <FitBoundsToPlaces places={mapPlaces} framedOnMount={framed !== null} />
{selectedDay && mapPlaces.length > 1 && ( {selectedDay && dayLine.length > 1 && (
<Polyline <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, // 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 // not the roads between them. A real route would mean sending the
// itinerary to a third party for every anonymous visitor of a shared // itinerary to a third party for every anonymous visitor of a shared
+87 -1
View File
@@ -3,7 +3,7 @@ import React from 'react';
import { render, screen, waitFor, act, fireEvent } from '../../tests/helpers/render'; import { render, screen, waitFor, act, fireEvent } from '../../tests/helpers/render';
import { Routes, Route } from 'react-router'; import { Routes, Route } from 'react-router';
import { resetAllStores, seedStore } from '../../tests/helpers/store'; 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 { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore'; import { useTripStore } from '../store/tripStore';
import { usePluginStore } from '../store/pluginStore'; 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 // Configurable usePlaceSelection mock — lets tests set a specific selected place
const mockPlaceSelectionState: { selectedPlaceId: number | null; selectedAssignmentId: number | null } = { const mockPlaceSelectionState: { selectedPlaceId: number | null; selectedAssignmentId: number | null } = {
selectedPlaceId: null, selectedPlaceId: null,
@@ -253,6 +281,8 @@ beforeEach(() => {
capturedTripMembersModalProps.current = {}; capturedTripMembersModalProps.current = {};
capturedFileManagerProps.current = {}; capturedFileManagerProps.current = {};
capturedPlaceInspectorProps.current = {}; capturedPlaceInspectorProps.current = {};
capturedRoadtripSidebarProps.current = {};
capturedTransportDetailModalProps.current = {};
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); 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 });
});
});
}); });
+1
View File
@@ -608,6 +608,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
if (TRANSPORT_TYPES.has(r.type)) setMapTransportDetail(r) if (TRANSPORT_TYPES.has(r.type)) setMapTransportDetail(r)
else openLinkedReservation?.(r) else openLinkedReservation?.(r)
}} }}
canEditBookings={can('reservation_edit', trip)}
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined} onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined} onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined} onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
@@ -449,6 +449,16 @@ describe('AdminSettingsTab', () => {
expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'true'); 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', () => { it('FE-ADMSET-031: the API keys card leads with the TREK index and no weather panel', () => {
renderTab(); renderTab();
+5 -3
View File
@@ -7,6 +7,7 @@ import CustomSelect from '../../components/shared/CustomSelect'
import GoogleOptions from './GoogleOptions' import GoogleOptions from './GoogleOptions'
import ProviderBlock from './ProviderBlock' import ProviderBlock from './ProviderBlock'
import TrekApiCard from './TrekApiCard' import TrekApiCard from './TrekApiCard'
import { placesGoogleOnlyHint } from '../../utils/placeSource'
import type { TranslationFn } from '../../types' import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin' import type { useAdmin } from './useAdmin'
@@ -523,12 +524,13 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</div> </div>
{/* The one row here that is about where a search goes rather than {/* The one row here that is about where a search goes rather than
what the key may be spent on. Without a key it is a promise the what the key may be spent on. Without a key, or with Amap or
search cannot keep, and the subtitle says so. */} 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 className="flex items-center justify-between gap-4 py-3">
<div> <div>
<p className="text-sm font-medium text-content-secondary">{t('admin.placesGoogleOnly.title')}</p> <p className="text-sm font-medium text-content-secondary">{t('admin.placesGoogleOnly.title')}</p>
<p className="text-xs text-content-faint mt-0.5">{t(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')}</p> <p className="text-xs text-content-faint mt-0.5">{t(placesGoogleOnlyHint(hasMapsKey, placesProvider))}</p>
</div> </div>
<ToggleSwitch <ToggleSwitch
on={placesGoogleOnly} on={placesGoogleOnly}
+2 -2
View File
@@ -4,7 +4,7 @@ import { ChevronRight } from 'lucide-react'
interface GoogleOptionsProps { interface GoogleOptionsProps {
/** "Google options" — the heading of the fold. */ /** "Google options" — the heading of the fold. */
title: string 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 summary: string
children: React.ReactNode children: React.ReactNode
} }
@@ -15,7 +15,7 @@ interface GoogleOptionsProps {
* Only these switches fold away. The key fields above stay visible: a field you * 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 * 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, * 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 * 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 * to screen readers without writing any of that, and it survives a re-render
+33 -1
View File
@@ -1,4 +1,4 @@
// FE-ADMHOOK-001 to FE-ADMHOOK-049 // FE-ADMHOOK-001 to FE-ADMHOOK-051
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import React from 'react'; import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -276,6 +276,38 @@ describe('useAdmin', () => {
expect(toastCalls.some(c => c.type === 'error')).toBe(true); 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 () => { it('FE-ADMHOOK-016: handleSaveWebauthn trims and re-reads the app config', async () => {
let body: Record<string, unknown> | null = null; let body: Record<string, unknown> | null = null;
server.use( server.use(
+15 -5
View File
@@ -109,11 +109,6 @@ export function useAdmin() {
// reads the switch on the server, so nothing else in the client needs it. // reads the switch on the server, so nothing else in the client needs it.
const [placesGoogleOnly, setPlacesGoogleOnlyState] = useState<boolean>(false) const [placesGoogleOnly, setPlacesGoogleOnlyState] = useState<boolean>(false)
useEffect(() => { adminApi.getPlacesGoogleOnly().then(d => setPlacesGoogleOnlyState(d.enabled)).catch(() => {}) }, []) useEffect(() => { adminApi.getPlacesGoogleOnly().then(d => setPlacesGoogleOnlyState(d.enabled)).catch(() => {}) }, [])
const handleTogglePlacesGoogleOnly = async () => {
const next = !placesGoogleOnly
setPlacesGoogleOnlyState(next)
try { await adminApi.updatePlacesGoogleOnly(next) } catch { setPlacesGoogleOnlyState(!next) }
}
// Transit backend (#1699). googleKeySource says where a Google key would come // Transit backend (#1699). googleKeySource says where a Google key would come
// from for this admin — null means picking Google changes nothing, since the // from for this admin — null means picking Google changes nothing, since the
@@ -202,6 +197,9 @@ export function useAdmin() {
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false) const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled, logout } = useAuthStore() 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 navigate = useNavigate()
const toast = useToast() const toast = useToast()
@@ -364,12 +362,24 @@ export function useAdmin() {
} }
} }
const handleTogglePlacesGoogleOnly = async () => {
const next = !placesGoogleOnly
setPlacesGoogleOnlyState(next)
try {
await adminApi.updatePlacesGoogleOnly(next)
} catch (err: unknown) {
setPlacesGoogleOnlyState(!next)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const handleSavePlacesProvider = async (value: string) => { const handleSavePlacesProvider = async (value: string) => {
const previous = placesProvider const previous = placesProvider
setPlacesProvider(value) setPlacesProvider(value)
setSavingPlacesProvider(true) setSavingPlacesProvider(true)
try { try {
await authApi.updateAppSettings({ places_provider: value }) await authApi.updateAppSettings({ places_provider: value })
setStorePlacesProvider(value)
toast.success(t('admin.placesProvider.saved')) toast.success(t('admin.placesProvider.saved'))
} catch (err: unknown) { } catch (err: unknown) {
setPlacesProvider(previous) setPlacesProvider(previous)
@@ -30,6 +30,9 @@ export interface PublicPhoto {
caption?: string | null caption?: string | null
// 'image' (default) or 'video' (#823) // 'image' (default) or 'video' (#823)
media_type?: string | null 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 duration_ms?: number | null
} }
@@ -44,6 +47,9 @@ export interface PublicGalleryPhoto {
caption?: string | null caption?: string | null
// 'image' (default) or 'video' (#823) // 'image' (default) or 'video' (#823)
media_type?: string | null 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 duration_ms?: number | null
/** Where and when the picture was taken (#1614). Absent for most photos, and the /** Where and when the picture was taken (#1614). Absent for most photos, and the
* coordinates are withheld entirely unless the owner shared the map. */ * 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 type { JourneyMapHandle } from '../../components/Journey/JourneyMap'
import { useIsMobile } from '../../hooks/useIsMobile' import { useIsMobile } from '../../hooks/useIsMobile'
import { DAY_COLORS } from '../../components/Journey/dayColors' import { DAY_COLORS } from '../../components/Journey/dayColors'
import { posterlessVideo } from '../journeyDetail/JourneyDetailPage.helpers'
import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublicModel' import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublicModel'
/** /**
@@ -86,10 +87,14 @@ export function useJourneyPublic() {
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length], dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel, dayLabel,
// Through the share token, like every other picture on this page: the // 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 ?? []) photoUrls: (e.photos ?? [])
.filter(p => !posterlessVideo(p))
.slice(0, 3) .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]) }, [mapEntries, sortedDates, token])
@@ -1,5 +1,5 @@
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo' 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 React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react' import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext' 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) 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() 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 () => { it('FE-TP-HOOK-102: a member roster refresh replaces the cached list', async () => {
seedTrip() seedTrip()
const { result } = await renderPlanner() const { result } = await renderPlanner()
@@ -14,6 +14,7 @@ import { resolvePluginIcon } from '../../components/shared/PluginIcon'
import { useTranslation, translateApiError } from '../../i18n' import { useTranslation, translateApiError } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
import { getDayOrder } from '../../utils/dayOrder' import { getDayOrder } from '../../utils/dayOrder'
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import { isOvernightCategory } from '../../components/Roadtrip/stopKinds' import { isOvernightCategory } from '../../components/Roadtrip/stopKinds'
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft' import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
import type { BookingImportPreviewItem } from '@trek/shared' import type { BookingImportPreviewItem } from '@trek/shared'
@@ -215,7 +216,6 @@ export function useTripPlanner() {
}).catch(() => {}) }).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 tripPagePlugins = allPlugins.filter(p => p.type === 'trip-page')
const tripPluginIds = tripPagePlugins.map(p => p.id).join(',') const tripPluginIds = tripPagePlugins.map(p => p.id).join(',')
@@ -2095,6 +2095,7 @@ export function useTripPlanner() {
// place's lone assignment to hydrate & persist its times; with 0 or 2+ // 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). // assignments the time is ambiguous and the modal hides the fields (#1247).
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => { 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') { 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 visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)
const entry = Object.entries(assignments).find(([, visits]) => visits.some(visit => visit.id === visitId)) const entry = Object.entries(assignments).find(([, visits]) => visits.some(visit => visit.id === visitId))
@@ -2119,7 +2120,7 @@ export function useTripPlanner() {
setPlaceFormDayId(null) setPlaceFormDayId(null)
setServiceStopForm(false) setServiceStopForm(false)
setShowPlaceForm(true) 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. * How long the drive stands here, for every stop alike.
@@ -2134,8 +2135,9 @@ export function useTripPlanner() {
}, []) }, [])
const handleDeletePlace = useCallback((placeId) => { const handleDeletePlace = useCallback((placeId) => {
if (!can('place_edit', trip)) return
setDeletePlaceId(placeId) setDeletePlaceId(placeId)
}, []) }, [can, trip])
const confirmDeletePlace = useCallback(async () => { const confirmDeletePlace = useCallback(async () => {
if (!deletePlaceId) return if (!deletePlaceId) return
+7
View File
@@ -52,6 +52,10 @@ interface AuthState {
* into one "has a search key": which of the two is missing decides what the * into one "has a search key": which of the two is missing decides what the
* admin has to go and do. */ * admin has to go and do. */
hasAmapKey: boolean 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 serverTimezone: string
/** Server policy: all users must enable MFA */ /** Server policy: all users must enable MFA */
appRequireMfa: boolean appRequireMfa: boolean
@@ -81,6 +85,7 @@ interface AuthState {
setAppVersion: (val: string) => void setAppVersion: (val: string) => void
setHasMapsKey: (val: boolean) => void setHasMapsKey: (val: boolean) => void
setHasAmapKey: (val: boolean) => void setHasAmapKey: (val: boolean) => void
setPlacesProvider: (val: string) => void
setServerTimezone: (tz: string) => void setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void setTripRemindersEnabled: (val: boolean) => void
@@ -132,6 +137,7 @@ export const useAuthStore = create<AuthState>()(
appVersion: '', appVersion: '',
hasMapsKey: false, hasMapsKey: false,
hasAmapKey: false, hasAmapKey: false,
placesProvider: 'auto',
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false, appRequireMfa: false,
tripRemindersEnabled: false, tripRemindersEnabled: false,
@@ -402,6 +408,7 @@ export const useAuthStore = create<AuthState>()(
setAppVersion: (val: string) => set({ appVersion: val }), setAppVersion: (val: string) => set({ appVersion: val }),
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }), setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setHasAmapKey: (val: boolean) => set({ hasAmapKey: val }), setHasAmapKey: (val: boolean) => set({ hasAmapKey: val }),
setPlacesProvider: (val: string) => set({ placesProvider: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }), setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }), setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }), setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
+20 -14
View File
@@ -194,6 +194,20 @@ async function _writeDayToDb(dayId: number, state: TripStoreState): Promise<void
// ── Zustand event reducer ───────────────────────────────────────────────────── // ── 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> type StateApplier = (payload: Record<string, unknown>, state: TripStoreState) => Partial<TripStoreState>
/** /**
@@ -252,19 +266,8 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
} }
// Genuinely new — including a legitimate second assignment of a place // Genuinely new — including a legitimate second assignment of a place
// already on this day (no temp version to reconcile). Seated where the server // already on this day (no temp version to reconcile).
// put it: a booked night lands at the front of its day, and the rows behind it return { assignments: { ...state.assignments, [dayKey]: seatByOrder(existing, incoming) } }
// were moved up one there. Appending it with that index would leave it tied
// with the old first row and drawn second until the next reload.
const ordered = existing.slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
const at = Math.max(0, Math.min(incoming.order_index ?? ordered.length, ordered.length))
ordered.splice(at, 0, incoming)
return {
assignments: {
...state.assignments,
[dayKey]: ordered.map((a, i) => (a.order_index === i ? a : { ...a, order_index: i })),
}
}
}, },
'assignment:updated': (payload, state) => { 'assignment:updated': (payload, state) => {
const dayKey = String((payload.assignment as Assignment).day_id) const dayKey = String((payload.assignment as Assignment).day_id)
@@ -290,11 +293,14 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
const oldKey = String(payload.oldDayId) const oldKey = String(payload.oldDayId)
const newKey = String(payload.newDayId) const newKey = String(payload.newDayId)
const movedAssignment = payload.assignment as Assignment 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 { return {
assignments: { assignments: {
...state.assignments, ...state.assignments,
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id), [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),
} }
} }
}, },
+26 -6
View File
@@ -233,12 +233,20 @@
} }
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); } .trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
.trek-dash .hero-title { .trek-dash .hero-title {
font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; font-size: 104px; font-weight: 600; line-height: 1.12; letter-spacing: -0.045em; margin: 0;
text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4); 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 /* 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. */ and below have a fixed budget and a third line would run into the badge row. */
overflow-wrap: anywhere; overflow-wrap: anywhere;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; 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 ----------------- */ /* ----------------- boarding pass ----------------- */
@@ -427,6 +435,11 @@
.trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; } .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-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; } .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; } .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 /* Date rendered as a peer of the counts, set off by a vertical divider rather than
floating alone at the far left. */ floating alone at the far left. */
@@ -481,7 +494,15 @@
.trek-dash .trip-action-btn:hover { background: oklch(0.22 0.01 260 / .82); transform: scale(1.06); } .trek-dash .trip-action-btn: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-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-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 { 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-where svg { width: 12px; height: 12px; opacity: .8; }
.trek-dash .trip-body { padding: 18px 20px 20px; } .trek-dash .trip-body { padding: 18px 20px 20px; }
@@ -730,10 +751,9 @@
.trek-dash .trips.list-view .trip-card { grid-template-columns: 1fr; min-height: 0; } .trek-dash .trips.list-view .trip-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 { 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-cover-content { left: 16px; right: 16px; bottom: 11px; }
.trek-dash .trips.list-view .trip-name { /* Taller banner (110px), smaller type and the title 11px off the bottom: two
font-size: 18px; overflow: hidden; text-overflow: ellipsis; lines end below the badge here, so the phone row keeps them. */
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; .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-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 { margin-bottom: 0; justify-content: center; font-size: 12.5px; }
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 12.5px; } .trek-dash .trips.list-view .trip-dates .date-num { font-size: 12.5px; }
+87 -3
View File
@@ -6,10 +6,26 @@ import { describe, it, expect } from 'vitest'
// releaseNoticeCss.test.ts pins the notice's stacking. // releaseNoticeCss.test.ts pins the notice's stacking.
describe('dashboard css', () => { describe('dashboard css', () => {
const css = readFileSync(resolve(process.cwd(), 'src/styles/dashboard.css'), 'utf8') const css = readFileSync(resolve(process.cwd(), 'src/styles/dashboard.css'), 'utf8')
const block = (selector: string): string => { const within = (source: string, selector: string): string => {
const at = css.indexOf(selector) const at = source.indexOf(selector)
expect(at, `${selector} missing from dashboard.css`).toBeGreaterThan(-1) expect(at, `${selector} missing from dashboard.css`).toBeGreaterThan(-1)
return css.slice(at, css.indexOf('}', at) + 1) return source.slice(at, source.indexOf('}', at) + 1)
}
const block = (selector: string): string => within(css, selector)
const phone = (): string => {
const at = css.indexOf('@media (max-width: 720px)')
expect(at, 'the phone block').toBeGreaterThan(-1)
return css.slice(at, css.indexOf('\n}', at))
}
// The leading number of a declaration, undefined when the rule does not set it.
const number = (rule: string, prop: string): number | undefined => {
const found = new RegExp(`(?:^|[;{\\n])\\s*${prop}:\\s*(-?[\\d.]+)`).exec(rule)
return found ? Number(found[1]) : undefined
}
const value = (rule: string, prop: string): number => {
const found = number(rule, prop)
expect(found, `${prop} missing from ${rule.slice(0, 48)}`).toBeDefined()
return found!
} }
it('FE-DASH-CSS-001: a hero title that is one long word cannot widen the card', () => { it('FE-DASH-CSS-001: a hero title that is one long word cannot widen the card', () => {
@@ -24,4 +40,72 @@ describe('dashboard css', () => {
expect(title).toMatch(/-webkit-line-clamp:\s*2/) expect(title).toMatch(/-webkit-line-clamp:\s*2/)
expect(title).toMatch(/overflow:\s*hidden/) 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))
}
})
}) })
+33 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { offersGoogleRetry, sourceLabelFor } from './placeSource' import { googleHoldsSlot, offersGoogleRetry, placesGoogleOnlyHint, selectGoogleHoldsSlot, sourceLabelFor } from './placeSource'
describe('offersGoogleRetry', () => { describe('offersGoogleRetry', () => {
it('FE-PLACESOURCE-001: offers Google for a list the index or OpenStreetMap answered, only where a key exists', () => { it('FE-PLACESOURCE-001: offers Google for a list the index or OpenStreetMap answered, only where Google answers', () => {
expect(offersGoogleRetry('trek-places+openstreetmap', true)).toBe(true) expect(offersGoogleRetry('trek-places+openstreetmap', true)).toBe(true)
expect(offersGoogleRetry('openstreetmap', true)).toBe(true) expect(offersGoogleRetry('openstreetmap', true)).toBe(true)
expect(offersGoogleRetry('trek-places+plugin', true)).toBe(true) expect(offersGoogleRetry('trek-places+plugin', true)).toBe(true)
@@ -17,6 +17,37 @@ describe('offersGoogleRetry', () => {
}) })
}) })
describe('googleHoldsSlot', () => {
it('FE-PLACESOURCE-004: a key holds the slot under auto and google, not under Amap or OpenStreetMap', () => {
expect(googleHoldsSlot(true, 'auto')).toBe(true)
expect(googleHoldsSlot(true, 'google')).toBe(true)
// A stored key still serves photos and details here, but the server never
// walks the Google chain for a search, so a link that offered it would be dead.
expect(googleHoldsSlot(true, 'amap')).toBe(false)
expect(googleHoldsSlot(true, 'openstreetmap')).toBe(false)
expect(googleHoldsSlot(false, 'google')).toBe(false)
// Unrecognised reads as auto, as the server reads it.
expect(googleHoldsSlot(true, '')).toBe(true)
})
it('FE-PLACESOURCE-005: the store selector asks the same question of the auth state', () => {
expect(selectGoogleHoldsSlot({ hasMapsKey: true, placesProvider: 'auto' })).toBe(true)
expect(selectGoogleHoldsSlot({ hasMapsKey: true, placesProvider: 'openstreetmap' })).toBe(false)
expect(selectGoogleHoldsSlot({ hasMapsKey: false, placesProvider: 'auto' })).toBe(false)
})
})
describe('placesGoogleOnlyHint', () => {
it('FE-PLACESOURCE-006: names what the admin has to do before the switch does anything', () => {
expect(placesGoogleOnlyHint(false, 'auto')).toBe('admin.placesGoogleOnly.missingKey')
expect(placesGoogleOnlyHint(false, 'openstreetmap')).toBe('admin.placesGoogleOnly.missingKey')
expect(placesGoogleOnlyHint(true, 'amap')).toBe('admin.placesGoogleOnly.otherProvider')
expect(placesGoogleOnlyHint(true, 'openstreetmap')).toBe('admin.placesGoogleOnly.otherProvider')
expect(placesGoogleOnlyHint(true, 'auto')).toBe('admin.placesGoogleOnly.subtitle')
expect(placesGoogleOnlyHint(true, 'google')).toBe('admin.placesGoogleOnly.subtitle')
})
})
describe('sourceLabelFor', () => { describe('sourceLabelFor', () => {
it('FE-PLACESOURCE-003: a row names its own index, else the list it came from', () => { it('FE-PLACESOURCE-003: a row names its own index, else the list it came from', () => {
const t = (k: string) => k const t = (k: string) => k
+46 -15
View File
@@ -32,6 +32,52 @@ const SOURCE_KEYS: Record<string, string> = {
amap: 'places.source.amap', amap: 'places.source.amap',
} }
/**
* Whether a search can reach Google on this install at all.
*
* A stored key is not enough. The server walks the Google key chain only while
* the admin's provider choice leaves Google the keyed slot, which is `auto` or
* `google`; under Amap or OpenStreetMap the key still serves photos and
* details, but a search sent to Google is answered by the index as before.
* Anything unrecognised reads as `auto`, the way the server reads it.
*/
export function googleHoldsSlot(hasMapsKey: boolean, placesProvider: string): boolean {
return hasMapsKey && placesProvider !== 'amap' && placesProvider !== 'openstreetmap'
}
/** The same question asked of the auth store, for `useAuthStore(selectGoogleHoldsSlot)`. */
export const selectGoogleHoldsSlot = (s: { hasMapsKey: boolean; placesProvider: string }): boolean =>
googleHoldsSlot(s.hasMapsKey, s.placesProvider)
/**
* Whether a result list can be sent to Google instead.
*
* The index and OpenStreetMap answer first, and Google is only asked when they
* find nothing, so a list that has the wrong place on it never reaches Google
* on its own. The link that sends it there is offered when Google holds the
* keyed slot (googleHoldsSlot above) and this list did not already come from
* Google: a list Google produced, alone or as the empty-case fallback, has
* nowhere further to go, and the offline cache is not a search at all.
*/
export function offersGoogleRetry(listSource: string, googleAnswers: boolean): boolean {
if (!googleAnswers || !listSource) return false
return !listSource.split('+').includes('google') && listSource !== 'offline-cache'
}
/**
* The line under the admin's "Search with Google only" switch.
*
* Two ways the switch can be a promise the search cannot keep, and they ask
* different things of the admin: paste a key, or hand the keyed slot back to
* Google. The subtitle that says what the switch does is only true of the
* third state.
*/
export function placesGoogleOnlyHint(hasMapsKey: boolean, placesProvider: string): string {
if (!hasMapsKey) return 'admin.placesGoogleOnly.missingKey'
if (!googleHoldsSlot(hasMapsKey, placesProvider)) return 'admin.placesGoogleOnly.otherProvider'
return 'admin.placesGoogleOnly.subtitle'
}
/** /**
* The label for one row. * The label for one row.
* *
@@ -40,21 +86,6 @@ const SOURCE_KEYS: Record<string, string> = {
* answered the call: Google never marks its places, and a merged list marks only * answered the call: Google never marks its places, and a merged list marks only
* the index side, so an unmarked row in one is OpenStreetMap by elimination. * the index side, so an unmarked row in one is OpenStreetMap by elimination.
*/ */
/**
* Whether a result list can be sent to Google instead.
*
* The index and OpenStreetMap answer first, and Google is only asked when they
* find nothing, so a list that has the wrong place on it never reaches Google
* on its own. The link that sends it there is offered when the instance has a
* Google key and this list did not already come from Google: a list Google
* produced, alone or as the empty-case fallback, has nowhere further to go, and
* the offline cache is not a search at all.
*/
export function offersGoogleRetry(listSource: string, hasMapsKey: boolean): boolean {
if (!hasMapsKey || !listSource) return false
return !listSource.split('+').includes('google') && listSource !== 'offline-cache'
}
export function sourceLabelFor(place: unknown, listSource: string, t: TranslationFn): string | null { export function sourceLabelFor(place: unknown, listSource: string, t: TranslationFn): string | null {
const own = (place as { source?: unknown } | null)?.source const own = (place as { source?: unknown } | null)?.source
const source = typeof own === 'string' && own const source = typeof own === 'string' && own
+1
View File
@@ -187,6 +187,7 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
// resolveVisibleConnectionIds returns an array, and consumers call .includes(). // resolveVisibleConnectionIds returns an array, and consumers call .includes().
visibleConnections: [] as number[], visibleConnections: [] as number[],
roadtripConnections: [] as number[],
toggleConnection: vi.fn(), toggleConnection: vi.fn(),
allConnectionsShown: false, allConnectionsShown: false,
toggleAllConnections: vi.fn(), toggleAllConnections: vi.fn(),
+38 -11
View File
@@ -50,21 +50,48 @@ Date.prototype.toLocaleDateString = function (locales?: Intl.LocalesArgument, op
return _origToLocaleDateString.call(this, locales ?? 'en-US', options) return _origToLocaleDateString.call(this, locales ?? 'en-US', options)
} }
// window.matchMedia — used by dark mode / responsive components // window.matchMedia — used by dark mode / responsive components.
// Width queries are answered from window.innerWidth, which is what a test sets
// when it wants a phone viewport, and the lists re-evaluate on a resize event,
// so a component that follows the breakpoint can be driven from a test the same
// way the browser drives it. Every other query keeps the old constant false.
const mediaLists = new Set<{ media: string; matches: boolean; listeners: Set<(e: MediaQueryListEvent) => void> }>()
function widthMatches(query: string): boolean {
const max = /\(\s*max-width:\s*(\d+)px\s*\)/.exec(query)
if (max) return window.innerWidth <= Number(max[1])
const min = /\(\s*min-width:\s*(\d+)px\s*\)/.exec(query)
if (min) return window.innerWidth >= Number(min[1])
return false
}
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
value: vi.fn().mockImplementation((query: string) => ({ value: vi.fn().mockImplementation((query: string) => {
matches: false, const entry = { media: query, matches: widthMatches(query), listeners: new Set<(e: MediaQueryListEvent) => void>() }
media: query, mediaLists.add(entry)
onchange: null, return {
addListener: vi.fn(), get matches() { return entry.matches },
removeListener: vi.fn(), media: query,
addEventListener: vi.fn(), onchange: null,
removeEventListener: vi.fn(), addListener: vi.fn(),
dispatchEvent: vi.fn(), removeListener: vi.fn(),
})), addEventListener: vi.fn((_type: string, fn: (e: MediaQueryListEvent) => void) => entry.listeners.add(fn)),
removeEventListener: vi.fn((_type: string, fn: (e: MediaQueryListEvent) => void) => entry.listeners.delete(fn)),
dispatchEvent: vi.fn(),
}
}),
}); });
window.addEventListener('resize', () => {
for (const entry of mediaLists) {
const matches = widthMatches(entry.media)
if (matches === entry.matches) continue
entry.matches = matches
for (const fn of entry.listeners) fn({ matches, media: entry.media } as MediaQueryListEvent)
}
})
// IntersectionObserver — used by lazy loading // IntersectionObserver — used by lazy loading
// Must use a class or regular function (not arrow function) so 'new IntersectionObserver()' works // Must use a class or regular function (not arrow function) so 'new IntersectionObserver()' works
class _MockIntersectionObserver { class _MockIntersectionObserver {
@@ -347,6 +347,35 @@ describe('MAdminSettingsSection', () => {
expect(admin.setPlacesDetailsEnabledState).toHaveBeenLastCalledWith(true); expect(admin.setPlacesDetailsEnabledState).toHaveBeenLastCalledWith(true);
}); });
it('FE-MOB-ASET-018b: the Google-only row says what it needs without a key and toggles through the hook', async () => {
const user = userEvent.setup();
const admin = renderSettings();
await user.click(screen.getByRole('button', { name: 'What the key is used for' }));
expect(screen.getByText(/Needs a Google Maps API key/)).toBeInTheDocument();
expect(toggle('Search with Google only')).toHaveAttribute('aria-checked', 'false');
await user.click(toggle('Search with Google only'));
expect(admin.handleTogglePlacesGoogleOnly).toHaveBeenCalledTimes(1);
});
it('FE-MOB-ASET-018c: with a key the Google-only row explains what it changes, unless another provider holds the slot', async () => {
const user = userEvent.setup();
const { unmount } = render(<Harness admin={buildAdminHook({ hasMapsKey: true, placesGoogleOnly: true })} />);
await user.click(screen.getByRole('button', { name: 'What the key is used for' }));
expect(screen.getByText(/Every search and every suggestion goes to Google Places/)).toBeInTheDocument();
expect(toggle('Search with Google only')).toHaveAttribute('aria-checked', 'true');
unmount();
// The server ignores the switch under Amap or OpenStreetMap, and the row
// must not promise otherwise just because a key is stored.
render(<Harness admin={buildAdminHook({ hasMapsKey: true, placesGoogleOnly: true, placesProvider: 'amap' })} />);
await user.click(screen.getByRole('button', { name: 'What the key is used for' }));
expect(screen.queryByText(/Every search and every suggestion goes to Google Places/)).not.toBeInTheDocument();
expect(screen.queryByText(/Needs a Google Maps API key/)).not.toBeInTheDocument();
const row = toggle('Search with Google only').closest<HTMLElement>('.justify-between')!;
expect(row.textContent).toMatch(/provider/i);
});
it('FE-MOB-ASET-019: the API-keys card is about keys, and weather needs none', () => { it('FE-MOB-ASET-019: the API-keys card is about keys, and weather needs none', () => {
// The Open-Meteo panel left this card, as it did on the desktop tab: it takes // The Open-Meteo panel left this card, as it did on the desktop tab: it takes
// no key and has nothing to configure, so it had no business in a card about // no key and has nothing to configure, so it had no business in a card about
@@ -1,4 +1,4 @@
// FE-MOB-JDET-001 to FE-MOB-JDET-037 // FE-MOB-JDET-001 to FE-MOB-JDET-039
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '../../../helpers/render'; import { render, screen, fireEvent, waitFor, act } from '../../../helpers/render';
import MJourneyDetail from '../../../../src/mobile/screens/journey/MJourneyDetail'; import MJourneyDetail from '../../../../src/mobile/screens/journey/MJourneyDetail';
@@ -317,6 +317,15 @@ describe('MJourneyDetail', () => {
expect(document.querySelector('.absolute.inset-0.flex.items-center')).toBeInTheDocument(); expect(document.querySelector('.absolute.inset-0.flex.items-center')).toBeInTheDocument();
}); });
it('FE-MOB-JDET-039: a provider clip shows the poster the provider serves, as the desktop gallery does', () => {
// A provider row never records a local thumbnail path, so the check the desktop
// shares (posterlessVideo) must be the one used here too (#2341 follow-up).
const gallery = [buildGalleryPhoto({ media_type: 'video', provider: 'immich', asset_id: 'a1', thumbnail_path: null })];
setup({ view: 'gallery', current: buildDetail({ gallery }) });
expect(document.querySelector('img[src="/api/photos/900/thumbnail"]')).toBeInTheDocument();
expect(document.querySelector('svg.lucide-play')).toBeInTheDocument();
});
it('FE-MOB-JDET-014: uploading from the device sends the picked files and reloads', async () => { it('FE-MOB-JDET-014: uploading from the device sends the picked files and reloads', async () => {
const { hook, container } = setup({ view: 'gallery' }); const { hook, container } = setup({ view: 'gallery' });
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement; const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
@@ -1306,3 +1306,29 @@ describe('MJourneyEntrySheet external photos', () => {
expect(onSave.mock.calls[0][0]).toMatchObject({ type: 'entry' }); expect(onSave.mock.calls[0][0]).toMatchObject({ type: 'entry' });
}); });
}); });
// FE-MOB-JENTRY-055 to FE-MOB-JENTRY-056: the phone half of the desktop editor's
// play badge for a clip that came up without a poster (#2341).
describe('MJourneyEntrySheet clips', () => {
it('FE-MOB-JENTRY-055: a linked clip without a poster is a play badge, not a request for its thumbnail', () => {
const clip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: null };
mountSheet(buildEntry({ id: 5, photos: [clip] }));
expect(document.querySelector('img[src="/api/photos/100/thumbnail"]')).not.toBeInTheDocument();
expect(document.querySelector('.h-16 svg.lucide-play')).toBeInTheDocument();
});
it('FE-MOB-JENTRY-056: the gallery picker draws the same badge, and a clip with a poster its poster', async () => {
const user = userEvent.setup();
const bare = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: null };
const withPoster = { ...buildGalleryPhoto(201), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' };
mountSheet(buildEntry(), { galleryPhotos: [bare, withPoster] });
await user.click(screen.getByRole('button', { name: 'From Gallery' }));
expect(document.querySelector('img[src="/api/photos/200/thumbnail"]')).not.toBeInTheDocument();
expect(document.querySelector('img[src="/api/photos/201/thumbnail"]')).toBeInTheDocument();
expect(document.querySelectorAll('svg.lucide-play')).toHaveLength(1);
});
});
@@ -265,17 +265,19 @@ describe('MSettingsMcp', () => {
expect(writeText).toHaveBeenNthCalledWith(2, 'sec-copy'); expect(writeText).toHaveBeenNthCalledWith(2, 'sec-copy');
}); });
it('FE-MOB-SETMCP-011: a failed registration surfaces an error toast and keeps the form open', async () => { it('FE-MOB-SETMCP-011: a refused registration surfaces the reason the server gave and keeps the form open', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use(http.post('/api/oauth/clients', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))); server.use(http.post('/api/oauth/clients', () =>
HttpResponse.json({ error: 'Redirect URI must use HTTPS, loopback HTTP, or a private custom scheme: http://192.168.1.5/cb' }, { status: 400 })));
renderScreen(); renderScreen();
await user.click(screen.getByRole('button', { name: 'New Client' })); await user.click(screen.getByRole('button', { name: 'New Client' }));
await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'Broken'); await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'Broken');
fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://localhost' } }); fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://192.168.1.5/cb' } });
await user.click(screen.getByRole('button', { name: 'Register Client' })); await user.click(screen.getByRole('button', { name: 'Register Client' }));
await screen.findByText('Failed to register OAuth client'); await screen.findByText(/Redirect URI must use HTTPS/);
expect(screen.queryByText('Failed to register OAuth client')).toBeNull();
expect(screen.getByText('Register OAuth Client')).toBeInTheDocument(); expect(screen.getByText('Register OAuth Client')).toBeInTheDocument();
}); });
@@ -3,17 +3,27 @@ import MBookingsTab from '../../../../src/mobile/screens/trip/tabs/MBookingsTab'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload' import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types' import type { Day, Reservation, TripFile } from '../../../../src/types'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { buildSettings } from '../../../helpers/factories' import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { seedStore } from '../../../helpers/store'
// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-018 // FE-MOB-BKTAB-001 to FE-MOB-BKTAB-023
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({ vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()), ...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(), openFile: vi.fn(),
})) }))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
),
}))
const SEAT_MAP = { id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'reservation-detail' } as const
const DAYS = [ const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null }, { id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' }, { id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
@@ -93,6 +103,7 @@ function cardOf(title: string): HTMLElement {
describe('MBookingsTab', () => { describe('MBookingsTab', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(openFile).mockClear() vi.mocked(openFile).mockClear()
seedStore(usePluginStore, { plugins: [] })
}) })
it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => { it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => {
@@ -285,4 +296,29 @@ describe('MBookingsTab', () => {
// the check-in cell and the time field both read 09:00 // the check-in cell and the time field both read 09:00
expect(within(card).getAllByText('09:00')).toHaveLength(2) expect(within(card).getAllByText('09:00')).toHaveLength(2)
}) })
it('FE-MOB-BKTAB-022: mounts a reservation-detail plugin on every booking card, scoped to that booking (#2440)', () => {
seedStore(usePluginStore, {
plugins: [SEAT_MAP, { id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'sidebar' }],
})
const { planner: p } = renderTab()
const frame = within(cardOf('Hotel Granvia')).getByTestId('plugin-frame')
expect(frame).toHaveAttribute('data-plugin', 'seat-map')
expect(frame).toHaveAttribute('data-trip', '7')
expect(frame).toHaveAttribute('data-reservation', '201')
expect(frame).toHaveAttribute('data-surface', 'detail-slot')
// the slot sits outside the card's body button: a tap in it opens no editor
fireEvent.click(frame)
expect(p.setShowReservationModal).not.toHaveBeenCalled()
expect(within(cardOf('Bamboo walk')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '204')
expect(screen.getAllByTestId('plugin-frame')).toHaveLength(4)
expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull()
})
it('FE-MOB-BKTAB-023: compact mode drops the plugin frames with the rest of the body', () => {
seedStore(usePluginStore, { plugins: [SEAT_MAP] })
renderTab(planner(), buildShell({ bookingsCompact: true }))
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument()
})
}) })
@@ -94,6 +94,10 @@ describe('MExportSheet', () => {
expect(arg.trip).toBe(planner.trip) expect(arg.trip).toBe(planner.trip)
expect(arg.days).toBe(planner.days) expect(arg.days).toBe(planner.days)
expect(arg.places).toBe(planner.places) expect(arg.places).toBe(planner.places)
// The raw store, not the hook's filtered list: the export applies the plan's
// own predicate, and the switch travels with it.
expect(arg.assignments).toBe(planner.storedAssignments)
expect(arg.showServiceStops).toBe(true)
expect(arg.reservations).toBe(planner.reservations) expect(arg.reservations).toBe(planner.reservations)
expect(arg.categories).toBe(planner.categories) expect(arg.categories).toBe(planner.categories)
// dayNotes come out of the store keyed by day, flattened with a numeric day_id. // dayNotes come out of the store keyed by day, flattened with a numeric day_id.
@@ -14,7 +14,7 @@ import type { AlternativeOverlay } from '../../../../src/components/Roadtrip/alt
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives' import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives' import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-035 // FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-037
// //
// The stage's pins come out of the trip store rather than the planner's map list, so the // The stage's pins come out of the trip store rather than the planner's map list, so the
// stage fixtures seed the store and leave `mapPlaces` to stand for what the plan tab shows. // stage fixtures seed the store and leave `mapPlaces` to stand for what the plan tab shows.
@@ -764,4 +764,37 @@ describe('MMapArea', () => {
expect((mocks.props.places as Place[]).map(p => p.id)).toEqual([11, 20, 21]) expect((mocks.props.places as Place[]).map(p => p.id)).toEqual([11, 20, 21])
expect(mocks.props.places).not.toBe(planner.roadtripMapPlaces) expect(mocks.props.places).not.toBe(planner.roadtripMapPlaces)
}) })
it('FE-MOB-MAPAREA-037: on the stage a seamed ride draws as its booking, whether or not it was toggled on', () => {
// The ride's own leg carries no geometry (carrierLeg), so the booking's arc is the
// only thing that joins the two terminals on the map. The planner folds the seamed
// bookings into `roadtripConnections`; the stage has to read that list, not the
// plan tab's toggles, or the road ends at the departure gate and picks up again at
// the arrival with nothing between.
const base = stagePlanner(3)
const [hamburg] = stageDay().stops
const terminal = (role: 'departure' | 'arrival') => ({
...hamburg,
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
placeId: -70,
name: role === 'departure' ? 'Hamburg Airport' : 'Munich Airport',
carrier: { reservationId: 70, type: 'flight', role, title: 'LH 2020', code: null, at: null },
})
const seamed = { ...stageDay(), stops: [hamburg, terminal('departure'), terminal('arrival')] } as unknown as RoadtripDay
const planner = {
...base,
roadtripRoutes: { ...base.roadtripRoutes, days: [seamed] },
visibleConnections: [],
roadtripConnections: [70],
} as TripPlanner
const shell = stageShell()
const { rerender } = render(<MMapArea planner={planner} shell={shell} />)
expect(mocks.props.visibleConnectionIds).toEqual([70])
// The plan tab keeps the traveller's own toggles, as the desktop does off road trip mode.
rerender(<MMapArea planner={planner} shell={{ ...shell, trTab: 'plan' }} />)
expect(mocks.props.visibleConnectionIds).toBe(planner.visibleConnections)
expect(mocks.props.visibleConnectionIds).toEqual([])
})
}) })
@@ -622,6 +622,19 @@ describe('MPlanTimeline', () => {
expect(mocks.tl.removeAssignment).toHaveBeenCalledWith(MUSEUM) expect(mocks.tl.removeAssignment).toHaveBeenCalledWith(MUSEUM)
}) })
it('FE-MOB-PLTL-048: without place_edit a place row keeps remove and reorder but loses its edit circle (#2446)', () => {
renderTimeline({}, { can: vi.fn((action: string) => action !== 'place_edit') as TripPlanner['can'] }, { mode: 'edit' })
// the place row keeps its remove circle and loses the edit one; the
// transport and note rows keep theirs, editing those is a day right
const row = screen.getByText('Museum').closest('[role="button"]') as HTMLElement
expect(within(row).getByLabelText('planner.removeFromDay')).toBeInTheDocument()
expect(within(row).queryByLabelText('common.edit')).not.toBeInTheDocument()
fireEvent.click(within(row).getByLabelText('planner.removeFromDay'))
expect(mocks.tl.removeAssignment).toHaveBeenCalledWith(MUSEUM)
expect(mocks.tl.editAssignment).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-037: the transit row opens the journey view from its edit circle', () => { it('FE-MOB-PLTL-037: the transit row opens the journey view from its edit circle', () => {
renderTimeline({}, {}, { mode: 'edit' }) renderTimeline({}, {}, { mode: 'edit' })
@@ -1,15 +1,15 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '../../../helpers/render' import { fireEvent, render, screen } from '../../../helpers/render'
import { import {
RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome, RtAutoRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome,
} from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripRows' } from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripRows'
import type { StopRow } from '../../../../src/components/Roadtrip/roadtripRowModel' import type { RoadtripRow, StopRow } from '../../../../src/components/Roadtrip/roadtripRowModel'
import type { RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip' import type { CarrierTerminal, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../../../src/types' import type { Reservation, TranslationFn } from '../../../../src/types'
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch' import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion' import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
// FE-MOB-RTROW-001 to FE-MOB-RTROW-049 // FE-MOB-RTROW-001 to FE-MOB-RTROW-055
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy. // Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
const t: TranslationFn = (key, params) => const t: TranslationFn = (key, params) =>
@@ -237,6 +237,143 @@ describe('RtStopRow', () => {
expect(screen.getByText('roadtrip.warn.overnight')).toBeInTheDocument() expect(screen.getByText('roadtrip.warn.overnight')).toBeInTheDocument()
expect(container.querySelector('.lucide-moon')).not.toBeNull() expect(container.querySelector('.lucide-moon')).not.toBeNull()
}) })
it('FE-MOB-RTROW-050: a hire desk is a row on the road: the booking\'s clock under it, no number, and a disc nobody can turn into a pump', () => {
const onPickKind = vi.fn()
const desk = stopRow({
number: null,
time: '09:00',
stop: { ...stopRow().stop, assignmentId: -3000000180, placeId: -90, name: 'Sixt Hauptbahnhof', carrier: carrier('pickup') },
})
const { container } = render(<RtStopRow row={desk} chrome={chrome} onOpen={vi.fn()} onPickKind={onPickKind} />)
expect(screen.getByText('Sixt Hauptbahnhof')).toBeInTheDocument()
expect(screen.getByText('roadtrip.ride.pickup:09:00')).toBeInTheDocument()
expect(screen.queryByText('2')).toBeNull()
// The booking's own icon on the disc, and the disc is not a control even for an
// editor: nobody turns a rental desk into a petrol station.
expect(container.querySelector('.lucide-car')).not.toBeNull()
expect(screen.queryByRole('button', { name: 'roadtrip.stop.makeService' })).toBeNull()
expect(screen.queryByRole('button', { name: 'roadtrip.stop.kind' })).toBeNull()
expect(screen.getAllByRole('button')).toHaveLength(1)
})
})
/** One end of a flight, or one desk of a hire car, as the routing round seams it in. */
function carrier(role: CarrierTerminal['role'], over: Partial<CarrierTerminal> = {}): CarrierTerminal {
const car = role === 'pickup' || role === 'return'
return {
reservationId: car ? 90 : 70,
type: car ? 'car' : 'flight',
role,
title: car ? 'Sixt Hamburg' : 'LH 2020',
code: role === 'departure' ? 'HAM' : role === 'arrival' ? 'MUC' : null,
at: { departure: '13:20', arrival: '14:30', pickup: '09:00', return: '11:30' }[role],
...over,
}
}
const FLIGHT_SEG = { ...SEG, distance: 0, duration: 4200, distanceText: '', durationText: '1 h 10 min' }
function rideRow(over: Partial<Extract<RoadtripRow, { kind: 'ride' }>> = {}): Extract<RoadtripRow, { kind: 'ride' }> {
const end = (role: 'departure' | 'arrival'): StopRow => stopRow({
number: null,
time: role === 'departure' ? '12:20' : '14:30',
pinned: true,
stop: {
...stopRow().stop,
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
placeId: -70,
name: role === 'departure' ? 'Hamburg Airport' : 'Munich Airport',
carrier: carrier(role),
},
})
return { kind: 'ride', index: 1, carrier: carrier('departure'), seg: FLIGHT_SEG, departure: end('departure'), arrival: end('arrival'), ...over }
}
describe('RtRideRow', () => {
it('FE-MOB-RTROW-051: a ride is one block: the booking and its minutes over both terminals, each with its code, its timetable line and its clock', () => {
render(<RtRideRow row={rideRow()} chrome={chrome} />)
expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument()
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(screen.getByText('roadtrip.ride.departure:13:20')).toBeInTheDocument()
expect(screen.getByText('12:20')).toBeInTheDocument()
expect(screen.getByText('Munich Airport')).toBeInTheDocument()
expect(screen.getByText('MUC')).toBeInTheDocument()
expect(screen.getByText('roadtrip.ride.arrival:14:30')).toBeInTheDocument()
expect(screen.getByText('14:30')).toBeInTheDocument()
// The terminals take no number: Hamburg is 1 and Munich is 2 either side of them.
expect(screen.queryByText('2')).toBeNull()
})
it('FE-MOB-RTROW-052: the block is one tap target that opens the booking, and plain without a handler', () => {
const onOpen = vi.fn()
const opens = render(<RtRideRow row={rideRow()} chrome={chrome} onOpen={onOpen} />)
// One control for the whole ride, not one per terminal.
expect(screen.getAllByRole('button')).toHaveLength(1)
fireEvent.click(screen.getByText('Hamburg Airport'))
fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min'))
expect(onOpen).toHaveBeenCalledTimes(2)
opens.unmount()
render(<RtRideRow row={rideRow()} chrome={chrome} />)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByText('Munich Airport')).toBeInTheDocument()
})
it('FE-MOB-RTROW-053: a ride the timetable gives no minutes for is still the booking, and its clocks follow the twelve hour setting', () => {
render(<RtRideRow row={rideRow({ seg: undefined })} chrome={{ ...chrome, is12h: true }} />)
expect(screen.getByText('LH 2020')).toBeInTheDocument()
expect(screen.queryByText(/LH 2020 ·/)).toBeNull()
expect(screen.getByText('roadtrip.ride.departure:1:20 PM')).toBeInTheDocument()
expect(screen.getByText('12:20 PM')).toBeInTheDocument()
expect(screen.queryByText('13:20')).toBeNull()
})
})
describe('RtBookingChips', () => {
const booking = (over: Partial<Reservation> = {}): Reservation =>
({ id: 11, trip_id: 1, title: 'Tisch Bullerei', type: 'restaurant', status: 'confirmed', day_id: 2, reservation_time: '2026-10-05T19:30', ...over }) as Reservation
it('FE-MOB-RTROW-054: a chip per booking with its name and its clock, each opening its booking, and plain without a handler', () => {
const onOpen = vi.fn()
const bookings = [booking(), booking({ id: 13, title: 'Elbphilharmonie', type: 'event', reservation_time: null })]
const opens = render(<RtBookingChips bookings={bookings} chrome={{ ...chrome, is12h: true }} canEdit onOpen={onOpen} />)
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
// The clock in the reader's own format, and none at all for a booking without one.
expect(screen.getByText('7:30 PM')).toBeInTheDocument()
expect(screen.queryByText('19:30')).toBeNull()
expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument()
expect(screen.getAllByRole('button')).toHaveLength(2)
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(onOpen).toHaveBeenCalledWith(bookings[0])
opens.unmount()
render(<RtBookingChips bookings={bookings} chrome={chrome} canEdit />)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByText('19:30')).toBeInTheDocument()
})
it('FE-MOB-RTROW-055: a reader who may not edit bookings gets a plain table chip and a transport chip that still opens', () => {
const onOpen = vi.fn()
const bookings = [booking(), booking({ id: 16, title: 'Taxi to the pier', type: 'taxi', reservation_time: null })]
render(<RtBookingChips bookings={bookings} chrome={chrome} canEdit={false} onOpen={onOpen} />)
// A transport has a detail view anybody may look at; a table has only its editor,
// so the chip stays a chip rather than a button that does nothing (#2012).
expect(screen.getByText('Tisch Bullerei').closest('button')).toBeNull()
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Taxi to the pier'))
expect(onOpen).toHaveBeenCalledWith(bookings[1])
expect(screen.getAllByRole('button')).toHaveLength(1)
})
}) })
describe('RtLegRow', () => { describe('RtLegRow', () => {
@@ -5,11 +5,11 @@ import { resetAllStores, seedStore } from '../../../helpers/store'
import { useTripStore } from '../../../../src/store/tripStore' import { useTripStore } from '../../../../src/store/tripStore'
import MRoadtripTab from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripTab' import MRoadtripTab from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripTab'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Day, Place } from '../../../../src/types' import type { Day, Place, Reservation } from '../../../../src/types'
import type { RoadtripDay, RoadtripRoutes, RouteSegment } from '@trek/shared/roadtrip' import type { CarrierTerminal, RoadtripDay, RoadtripRoutes, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives' import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-053 // FE-MOB-RTTAB-001 to FE-MOB-RTTAB-057
// //
// The stage bar pictures the place its day ends at. It reads that place out of the trip // The stage bar pictures the place its day ends at. It reads that place out of the trip
// store rather than the planner, the unfiltered list, so the picture tests seed the store. // store rather than the planner, the unfiltered list, so the picture tests seed the store.
@@ -734,6 +734,134 @@ describe('MRoadtripTab', () => {
}) })
}) })
// The rides and the hire car the routing round seams into the day, and the day's
// other bookings (#2428). The rows themselves are pinned in MRoadtripRows.test; this
// is the wiring: which sheet a tap on them opens, and for whom.
describe('a ride, a hire desk and the day\'s bookings', () => {
const entry = (arrival: string, departure: string | null = arrival) => ({ arrival, departure, anchored: true, dayOffset: 0 })
const real = (assignmentId: number, placeId: number, name: string): RoadtripStop => ({
assignmentId, ownerDayId: 2, ownerIndex: 0, placeId, name,
lat: 53.55, lng: 9.99, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
} as unknown as RoadtripStop)
// A synthetic stop, keyed off its booking the way the routing round keys it.
const terminal = (carrier: CarrierTerminal, name: string): RoadtripStop => ({
...real(-(carrier.reservationId * 10 + (carrier.role === 'departure' || carrier.role === 'pickup' ? 0 : 1)), -carrier.reservationId, name),
carrier,
})
const flight = (role: 'departure' | 'arrival'): CarrierTerminal =>
({ reservationId: 70, type: 'flight', role, title: 'LH 2020', code: null, at: role === 'departure' ? '13:20' : '14:30' })
const desk = (role: 'pickup' | 'return'): CarrierTerminal =>
({ reservationId: 90, type: 'car', role, title: 'Sixt Hamburg', code: null, at: role === 'pickup' ? '09:00' : '11:30' })
/** Kyoto Station, a flight to Munich, then Munich: the ride is one block between two numbered stops. */
const flightStage = () => stage({
stops: [
real(503, 103, 'Kyoto Station'),
terminal(flight('departure'), 'Hamburg Airport'),
terminal(flight('arrival'), 'Munich Airport'),
real(505, 105, 'Munich'),
],
legs: [seg('12 km', '20 min'), { ...seg('', '1 h 10 min'), distance: 0, duration: 4200 }, seg('30 km', '35 min')],
schedule: { entries: [entry('09:00'), entry('12:20', '13:20'), entry('14:30'), entry('15:30')], warnings: [] },
legVias: [[], [], []], driveWarnings: [], spills: [], dryPoints: [],
})
/** A hire car picked up before the first stop: the desk is a row of its own on the road. */
const rentalStage = () => stage({
stops: [terminal(desk('pickup'), 'Sixt Hauptbahnhof'), real(503, 103, 'Kyoto Station'), real(505, 105, 'Munich')],
legs: [seg('12 km', '20 min'), seg('30 km', '35 min')],
schedule: { entries: [entry('09:00'), entry('09:20', '09:50'), entry('10:50')], warnings: [] },
legVias: [[], []], driveWarnings: [], spills: [], dryPoints: [],
})
const booking = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 7, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 2, ...over }) as Reservation
it('FE-MOB-RTTAB-054: a tap on a terminal opens its booking in the transport sheet, and no kind is offered on it', () => {
const { shell } = renderTab(planner({ roadtripRoutes: routes({ days: [rentalStage()] }) }))
fireEvent.click(screen.getByText('Sixt Hauptbahnhof'))
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 90 })
expect(shell.openSheet).not.toHaveBeenCalledWith('rtstop', expect.anything())
// The desk is unnumbered and its disc is no control: only the two real stops
// offer to become a stop on the way, and the count says two.
expect(screen.getAllByRole('button', { name: 'roadtrip.stop.makeService' })).toHaveLength(2)
expect(screen.getByText('roadtrip.ride.pickup:09:00')).toBeInTheDocument()
expect(screen.getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
})
it('FE-MOB-RTTAB-055: a ride is one block that opens its booking, between the two stops it joins', () => {
const { shell } = renderTab(planner({ roadtripRoutes: routes({ days: [flightStage()] }) }))
expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument()
expect(screen.getByText('roadtrip.ride.departure:13:20')).toBeInTheDocument()
expect(screen.getByText('roadtrip.ride.arrival:14:30')).toBeInTheDocument()
// Kyoto is 1 and Munich is 2: the terminals between them take no number.
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.queryByText('3')).toBeNull()
fireEvent.click(screen.getByText('Munich Airport'))
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 70 })
expect(shell.openSheet).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTTAB-056: a table hangs under its stop as a chip, one booked for no stop is listed under the day, and the night is neither', () => {
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-05-02T19:30', assignment_id: 503 }),
booking({ id: 13, title: 'Elbphilharmonie', type: 'event', reservation_time: '2026-05-02T20:00', place_id: 999 }),
booking({ id: 14, title: 'Tomorrow', type: 'event', day_id: 1, place_id: 105 }),
booking({ id: 15, title: 'Hotel Hafen', type: 'hotel', place_id: 105 }),
]
const p = planner({ roadtripRoutes: routes({ days: [flightStage()] }), reservations })
const { shell } = renderTab(p)
const kyoto = screen.getByText('Kyoto Station').closest('[role="button"]') as HTMLElement
// The chip sits in the block right after its stop's row.
expect(within(kyoto.nextElementSibling as HTMLElement).getByText('Tisch Bullerei')).toBeInTheDocument()
expect(screen.getByText('19:30')).toBeInTheDocument()
expect(screen.getByText('roadtrip.bookings.loose')).toBeInTheDocument()
expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument()
// Another day's booking is not this day's, and the night is the stay, not a chip.
expect(screen.queryByText('Tomorrow')).toBeNull()
expect(screen.queryByText('Hotel Hafen')).toBeNull()
// The chips are the stop's, not stops: the count stays at two.
expect(screen.getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
// A chip opens its booking in the editor, the way the place sheet opens a linked one.
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(p.setEditingReservation).toHaveBeenCalledWith(reservations[0])
expect(p.setShowReservationModal).toHaveBeenCalledWith(true)
expect(shell.openSheet).not.toHaveBeenCalled()
})
it('FE-MOB-RTTAB-057: a transport chip goes to the transport sheet, and a table is not even a button for a member who may not edit bookings', () => {
const reservations = [
booking({ id: 16, title: 'Taxi to the pier', type: 'taxi', reservation_time: '2026-05-02T08:15', assignment_id: 503 }),
booking({ id: 13, title: 'Elbphilharmonie', type: 'event', place_id: 999 }),
]
const p = planner({
roadtripRoutes: routes({ days: [flightStage()] }),
reservations,
can: (action: string) => action !== 'reservation_edit',
})
const { shell } = renderTab(p)
fireEvent.click(screen.getByText('Taxi to the pier'))
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 16 })
// The event has only its editor, which this member may not open, so the chip is a
// plain chip and not a control that does nothing when tapped (#2012).
expect(screen.getByText('Elbphilharmonie').closest('button')).toBeNull()
fireEvent.click(screen.getByText('Elbphilharmonie'))
expect(p.setEditingReservation).not.toHaveBeenCalled()
expect(p.setShowReservationModal).not.toHaveBeenCalled()
expect(shell.openSheet).toHaveBeenCalledTimes(1)
})
})
describe('day swipe', () => { describe('day swipe', () => {
it('FE-MOB-RTTAB-027: steps the stage without refitting the map, and announces the day it landed on', () => { it('FE-MOB-RTTAB-027: steps the stage without refitting the map, and announces the day it landed on', () => {
const p = planner() const p = planner()
@@ -3,17 +3,27 @@ import MTransportsTab from '../../../../src/mobile/screens/trip/tabs/MTransports
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload' import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types' import type { Day, Reservation, TripFile } from '../../../../src/types'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { buildSettings } from '../../../helpers/factories' import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { seedStore } from '../../../helpers/store'
// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-022 // FE-MOB-TRTAB-001 to FE-MOB-TRTAB-028
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({ vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()), ...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(), openFile: vi.fn(),
})) }))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
),
}))
const FLIGHT_TRACKER = { id: 'flight-tracker', name: 'Flight Tracker', type: 'widget', icon: null, slot: 'reservation-detail' } as const
const DAYS = [ const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null }, { id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' }, { id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
@@ -101,6 +111,7 @@ function cardOf(title: string): HTMLElement {
describe('MTransportsTab', () => { describe('MTransportsTab', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(openFile).mockClear() vi.mocked(openFile).mockClear()
seedStore(usePluginStore, { plugins: [] })
}) })
it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => { it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => {
@@ -322,4 +333,35 @@ describe('MTransportsTab', () => {
// nobody is assigned, so the traveler filter row stays away // nobody is assigned, so the traveler filter row stays away
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument() expect(screen.queryByTitle('Ada')).not.toBeInTheDocument()
}) })
it('FE-MOB-TRTAB-027: mounts a reservation-detail plugin on every card, scoped to that reservation (#2440)', () => {
seedStore(usePluginStore, {
plugins: [
FLIGHT_TRACKER,
{ id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'hero' },
{ id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'place-detail' },
],
})
const { shell } = renderTab()
const frame = within(cardOf('HND to ITM')).getByTestId('plugin-frame')
expect(frame).toHaveAttribute('data-plugin', 'flight-tracker')
expect(frame).toHaveAttribute('data-trip', '7')
expect(frame).toHaveAttribute('data-reservation', '101')
expect(frame).toHaveAttribute('data-surface', 'detail-slot')
// the slot sits outside the card's body button: a tap in it opens no sheet
fireEvent.click(frame)
expect(shell.openSheet).not.toHaveBeenCalled()
expect(within(cardOf('Metro to hotel')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '104')
// one frame per transport card, and only for the reservation-detail slot
expect(screen.getAllByTestId('plugin-frame')).toHaveLength(5)
expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull()
expect(document.querySelector('[data-plugin="seat-map"]')).toBeNull()
})
it('FE-MOB-TRTAB-028: compact mode drops the plugin frames with the rest of the body', () => {
seedStore(usePluginStore, { plugins: [FLIGHT_TRACKER] })
renderTab(planner(), buildShell({ transportsCompact: true }))
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument()
})
}) })
@@ -2,11 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { HttpResponse, delay, http } from 'msw' import { HttpResponse, delay, http } from 'msw'
import PlPlaceSearch from '../../../../src/mobile/screens/trip/sheets/PlPlaceSearch' import PlPlaceSearch from '../../../../src/mobile/screens/trip/sheets/PlPlaceSearch'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { useAuthStore } from '../../../../src/store/authStore'
import { buildPlanner } from '../../../helpers/mobileTrip' import { buildPlanner } from '../../../helpers/mobileTrip'
import { server } from '../../../helpers/msw/server' import { server } from '../../../helpers/msw/server'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render' import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
import { resetAllStores, seedStore } from '../../../helpers/store'
// FE-MOB-PLSRCH-001 to FE-MOB-PLSRCH-018 // FE-MOB-PLSRCH-001 to FE-MOB-PLSRCH-022b
// planner.t echoes the key, so labels/toasts are asserted as their keys. // planner.t echoes the key, so labels/toasts are asserted as their keys.
const LOUVRE = { const LOUVRE = {
@@ -41,6 +43,21 @@ function recordSearch(places: unknown[] = [LOUVRE]) {
}) })
} }
/**
* The index answers with the wrong place; only a search sent to Google on
* purpose answers with the right one. The real source strings, so the test
* pins what the line under the list actually switches on.
*/
function recordGoogleRetry() {
return http.post('/api/maps/search', async ({ request }) => {
const body = await request.json() as Record<string, unknown>
searchBodies.push(body)
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: 35.68, lng: 139.77 }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: 47.1, lng: -118.4 }], source: 'trek-places+openstreetmap' })
})
}
function setup(plannerOverrides: Partial<TripPlanner> = {}, locationBias?: Parameters<typeof PlPlaceSearch>[0]['locationBias']) { function setup(plannerOverrides: Partial<TripPlanner> = {}, locationBias?: Parameters<typeof PlPlaceSearch>[0]['locationBias']) {
const onPick = vi.fn() const onPick = vi.fn()
const onResolvingChange = vi.fn() const onResolvingChange = vi.fn()
@@ -54,6 +71,7 @@ function setup(plannerOverrides: Partial<TripPlanner> = {}, locationBias?: Param
describe('PlPlaceSearch', () => { describe('PlPlaceSearch', () => {
beforeEach(() => { beforeEach(() => {
resetAllStores()
autocompleteBodies = [] autocompleteBodies = []
searchBodies = [] searchBodies = []
}) })
@@ -410,4 +428,72 @@ describe('PlPlaceSearch', () => {
fireEvent.blur(input) fireEvent.blur(input)
await waitFor(() => expect(screen.queryByText('Louvre')).not.toBeInTheDocument()) await waitFor(() => expect(screen.queryByText('Louvre')).not.toBeInTheDocument())
}) })
it('FE-MOB-PLSRCH-019: a list the index answered offers Google instead, and the line sends the same query there alone', async () => {
seedStore(useAuthStore, { hasMapsKey: true })
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'places.searchGoogleInstead' }))
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument()
expect(searchBodies[0]).not.toHaveProperty('provider')
expect(searchBodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' })
// A list Google produced has nowhere further to go.
expect(screen.queryByRole('button', { name: 'places.searchGoogleInstead' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSRCH-020: without a Google key the list offers nothing', async () => {
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'places.searchGoogleInstead' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSRCH-021: a key alone is not enough: with Amap or OpenStreetMap picked the list offers nothing', async () => {
// The server only honours the request while Google holds the keyed slot;
// under another provider the line would re-run the same search and stay.
seedStore(useAuthStore, { hasMapsKey: true, placesProvider: 'amap' })
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'places.searchGoogleInstead' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSRCH-022: the line sends the query the list came from, whatever the field holds by then', async () => {
seedStore(useAuthStore, { hasMapsKey: true })
server.use(recordAutocomplete([]), recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
// The list stays while the field is retyped; the line still means this list.
fireEvent.change(input, { target: { value: 'Kyoto' } })
fireEvent.click(screen.getByRole('button', { name: 'places.searchGoogleInstead' }))
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument()
expect(searchBodies).toHaveLength(2)
expect(searchBodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' })
})
it('FE-MOB-PLSRCH-022b: the line still works after the field was cleared', async () => {
seedStore(useAuthStore, { hasMapsKey: true })
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
// An empty field used to make the tap a silent no-op.
fireEvent.change(input, { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'places.searchGoogleInstead' }))
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument()
expect(searchBodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' })
})
}) })

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