{
const text = document.body.textContent || '';
expect(text.indexOf('Dated train')).toBeLessThan(text.indexOf('Undated taxi'));
});
+
+ it('FE-PLANNER-RESP-079: an http booking link is rendered as an anchor', () => {
+ const res = buildReservation({ id: 1, title: 'Hotel', status: 'confirmed', url: 'https://hotel.example/booking' } as any);
+ render();
+ const link = screen.getByText('https://hotel.example/booking');
+ expect(link.tagName).toBe('A');
+ expect(link).toHaveAttribute('href', 'https://hotel.example/booking');
+ });
+
+ it('FE-PLANNER-RESP-080: a javascript: booking link stays plain text, never an href', () => {
+ const res = buildReservation({ id: 1, title: 'Hotel', status: 'confirmed', url: 'javascript:alert(1)' } as any);
+ render();
+ // The stored value stays readable, but nothing is clickable.
+ const shown = screen.getByText('javascript:alert(1)');
+ expect(shown.tagName).toBe('SPAN');
+ expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
+ });
});
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index 29f217f4d..88ec7e864 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -12,6 +12,7 @@ import {
TramFront, Footprints, StickyNote, ParkingSquare,
} from 'lucide-react'
import { openFile } from '../../utils/fileDownload'
+import { safeHttpUrl } from '../../utils/safeUrl'
import { TransitTitle, TransitLegChips, TransitMetaBadges, fmtTransitDuration } from './transitDisplay'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -116,6 +117,9 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
})()
const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id))
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
+ // A booking link is free-form text on rows written before the contract checked it,
+ // so only an http(s) value becomes a real anchor — the rest stays readable text.
+ const bookingUrl = safeHttpUrl(r.url)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const handleDelete = async () => {
@@ -432,8 +436,12 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('reservations.urlLabel')}
)}
diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts
index b3653355f..55c32438b 100644
--- a/client/src/hooks/useRouteCalculation.ts
+++ b/client/src/hooks/useRouteCalculation.ts
@@ -205,19 +205,28 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
polyline.push(c)
}
}
- for (let i = 0; i < run.length - 1; i++) {
+ // Neighbouring legs that resolve to the SAME mode travel as one
+ // multi-waypoint request — the router answers with one leg per pair, so
+ // the per-connector segments stay exactly as they were, and a day without
+ // per-leg overrides is back to a single call per run.
+ let i = 0
+ while (i < run.length - 1) {
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
- const straight = (): [number, number][] => [[run[i].lat, run[i].lng], [run[i + 1].lat, run[i + 1].lng]]
+ let end = i + 1
+ while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
+ const chunk = run.slice(i, end + 1)
+ const straight = (): [number, number][] => chunk.map(p => [p.lat, p.lng] as [number, number])
try {
- const r = await calculateRouteWithLegs([{ lat: run[i].lat, lng: run[i].lng }, { lat: run[i + 1].lat, lng: run[i + 1].lng }], { signal: controller.signal, profile: mode, tripId, dayId })
+ const r = await calculateRouteWithLegs(chunk.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: mode, tripId, dayId })
pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight())
- if (r.legs[0]) allLegs.push({ ...r.legs[0], mode })
+ for (const leg of r.legs) allLegs.push({ ...leg, mode })
if (r.vias) allVias.push(...r.vias)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') throw err
- // Routing failed for this leg — fall back to a straight line, no times.
+ // Routing failed for these legs — fall back to straight lines, no times.
pushCoords(straight())
}
+ i = end
}
if (polyline.length >= 2) polylines.push(polyline)
}
diff --git a/client/tests/integration/hooks/useRouteCalculation.test.ts b/client/tests/integration/hooks/useRouteCalculation.test.ts
index a8288a383..cd2c6f8b3 100644
--- a/client/tests/integration/hooks/useRouteCalculation.test.ts
+++ b/client/tests/integration/hooks/useRouteCalculation.test.ts
@@ -117,6 +117,48 @@ describe('useRouteCalculation', () => {
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS.map(s => ({ ...s, mode: 'driving' })));
});
+ it('FE-HOOK-ROUTE-023: consecutive legs of one mode go out as a single multi-waypoint request', async () => {
+ // Three places routed in the day default and a fourth reached on foot: two
+ // requests, not three, and the connectors stay one per pair in order.
+ (calculateRouteWithLegs as ReturnType).mockImplementation(
+ (waypoints: { lat: number; lng: number }[]) => Promise.resolve({
+ coordinates: [] as [number, number][],
+ distance: 0,
+ duration: 0,
+ legs: waypoints.slice(0, -1).map((w, i) => ({
+ ...MOCK_SEGMENTS[0],
+ from: [w.lat, w.lng] as [number, number],
+ to: [waypoints[i + 1].lat, waypoints[i + 1].lng] as [number, number],
+ })),
+ }),
+ );
+ const pts = [
+ { lat: 48.86, lng: 2.35 }, { lat: 48.87, lng: 2.36 },
+ { lat: 48.88, lng: 2.37 }, { lat: 48.89, lng: 2.38 },
+ ];
+ const assignments = pts.map((pt, i) => ({
+ ...buildAssignment({ day_id: 5, order_index: i, place: buildPlace(pt) }),
+ // The third place walks to the fourth; the first two use the day default.
+ leg_transport_mode: i === 2 ? 'walking' : null,
+ }));
+ const store = buildMockStore({ '5': assignments as never });
+
+ const { result } = renderHook(() => useRouteCalculation(store as TripStoreState, 5));
+ await act(async () => {});
+
+ const calls = (calculateRouteWithLegs as ReturnType).mock.calls;
+ expect(calls).toHaveLength(2);
+ expect(calls[0][0]).toEqual(pts.slice(0, 3));
+ expect(calls[0][1].profile).toBe('driving');
+ expect(calls[1][0]).toEqual(pts.slice(2));
+ expect(calls[1][1].profile).toBe('walking');
+ // One segment per pair, in the order they are walked.
+ expect(result.current.routeSegments.map(s => s.mode)).toEqual(['driving', 'driving', 'walking']);
+ expect(result.current.routeSegments.map(s => s.from)).toEqual([
+ [pts[0].lat, pts[0].lng], [pts[1].lat, pts[1].lng], [pts[2].lat, pts[2].lng],
+ ]);
+ });
+
it('FE-HOOK-ROUTE-006: assignments are sorted by order_index before extracting waypoints', async () => {
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
diff --git a/shared/src/reservation/reservation.schema.spec.ts b/shared/src/reservation/reservation.schema.spec.ts
index 2741a6b06..09895e568 100644
--- a/shared/src/reservation/reservation.schema.spec.ts
+++ b/shared/src/reservation/reservation.schema.spec.ts
@@ -1,5 +1,6 @@
import {
reservationCreateRequestSchema,
+ reservationUpdateRequestSchema,
reservationPositionsRequestSchema,
accommodationCreateBodySchema,
accommodationCreateRequestSchema,
@@ -20,6 +21,23 @@ describe('reservationCreateRequestSchema', () => {
).toBe(true);
expect(reservationCreateRequestSchema.safeParse({ location: 'x' }).success).toBe(false);
});
+
+ it('rejects a booking url that executes instead of navigating, on create and update', () => {
+ expect(reservationCreateRequestSchema.safeParse({ title: 'Hotel', url: 'javascript:alert(1)' }).success).toBe(false);
+ expect(reservationCreateRequestSchema.safeParse({ title: 'Hotel', url: 'JavaScript:alert(1)' }).success).toBe(false);
+ expect(reservationCreateRequestSchema.safeParse({ title: 'Hotel', url: ' \tjava\nscript:alert(1)' }).success).toBe(false);
+ expect(reservationCreateRequestSchema.safeParse({ title: 'Hotel', url: 'data:text/html,' }).success).toBe(false);
+ expect(reservationUpdateRequestSchema.safeParse({ url: 'vbscript:msgbox' }).success).toBe(false);
+ });
+
+ it('keeps every link people actually paste valid (no stored row becomes unsavable)', () => {
+ for (const url of ['https://hotel.example/booking', 'http://hotel.example', 'www.hotel.com', 'hotel.example/x?a=b', '']) {
+ expect(reservationCreateRequestSchema.safeParse({ title: 'Hotel', url }).success).toBe(true);
+ expect(reservationUpdateRequestSchema.safeParse({ url }).success).toBe(true);
+ }
+ expect(reservationUpdateRequestSchema.safeParse({ url: null }).success).toBe(true);
+ expect(reservationUpdateRequestSchema.safeParse({ location: 'x' }).success).toBe(true);
+ });
});
describe('reservationPositionsRequestSchema', () => {
diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts
index a0c239b2f..89c9b6131 100644
--- a/shared/src/reservation/reservation.schema.ts
+++ b/shared/src/reservation/reservation.schema.ts
@@ -202,11 +202,31 @@ export const accommodationSchema = z.object({
});
export type Accommodation = z.infer;
+/**
+ * A booking link, as it is rendered into an href by the reservations panel. It
+ * deliberately stays free-form — people paste bare hosts ("www.hotel.com") and
+ * provider deep links, and rows written before this check exist — so the contract
+ * only rejects the schemes that execute in this origin instead of opening a page.
+ * Same class of bug as placeWebsiteSchema, one step narrower so nobody's stored
+ * link becomes unsavable.
+ */
+export const reservationUrlSchema = z.string().refine(
+ // Browsers strip control characters and whitespace before they resolve the
+ // scheme, so a tab spliced into 'javascript:' still runs.
+ v => !/^(javascript|data|vbscript):/i.test(v.replace(/[\u0000-\u0020]/g, '')),
+ { message: 'must not be a javascript:, data: or vbscript: URL' },
+);
+
/** Reservation create: title is required; the many optional fields stay open. */
-export const reservationCreateRequestSchema = open.and(z.object({ title: z.string().min(1) }));
+export const reservationCreateRequestSchema = open.and(z.object({
+ title: z.string().min(1),
+ url: reservationUrlSchema.nullable().optional(),
+}));
export type ReservationCreateRequest = z.infer;
-export const reservationUpdateRequestSchema = open;
+export const reservationUpdateRequestSchema = open.and(z.object({
+ url: reservationUrlSchema.nullable().optional(),
+}));
export type ReservationUpdateRequest = z.infer;
/** Assign trip members/guests to a reservation (mirrors budget's PUT :id/members). */