Compare commits

..

12 Commits

Author SHA1 Message Date
Maurice 14c767a675 fix(client): keep the open-at rule on the client's library target, and settle CI
Array.prototype.at is past the client's lib target, so the journey open-at
rule indexes the array. The packing row test expects the title that names
every bringer, a carousel test acts without a floating promise, and the
lint baseline drops to the counts this branch reaches.
2026-11-04 19:07:39 +01:00
Maurice da324121bd fix(packing): name everyone who brings a shared item
A common item brought by several members showed the first bringer's avatar
and a bare "+2". The tooltip only named the first person, and on the phone
neither the row nor the item sheet said who else brings it (discussion
#2782).

packingBringerNames lists the bringer and every contributor; the desktop
row, the phone row and the phone item sheet use it for "brought by".
2026-11-04 17:53:15 +01:00
Maurice b41f6e3542 fix(journey): a newest-first share link opens on the latest day on the phone
The public journey page on a phone shows the map with a card carousel, which
always opened on the first entry. The owner's "newest first" choice for the
link was ignored there, so readers following a running trip had to swipe
through every day to reach the latest one (discussion #2851).

The rule for where a journey opens moves out of the journal hook into
journeyOpenAtEntryId, which the public page now uses as well: a newest-first
link opens on the latest day, otherwise a running journey opens on today as
the journal already does.

The carousel also settles on the card at the end it is scrolled to. The last
card can never reach the centre, so opening on the latest day used to
activate the day before it, in the journal as well.
2026-11-04 17:53:15 +01:00
Maurice 9f5d01ee09 test(shared): cover the stored Costs category and the hint the MCP reads 2026-11-04 15:29:29 +01:00
Maurice 67ca58f2a5 fix(mcp): a transport priced without a category is stored under its Costs key 2026-11-04 15:12:20 +01:00
Maurice 9eab19ea6c fix(mcp): hold two more tests to the fixed Costs keys
The budget prompt and the price link of a new transport now read the stored key.
2026-11-04 15:00:03 +01:00
Maurice 50ea9a74c9 fix(mcp): keep budget categories to the Costs keys, and say what a journal entry's visibility does
An assistant writing expenses through the MCP was told "e.g. Accommodation, Food,
Transport" and stored exactly that, so "Food" and "food" sat side by side in the
MCP totals while the Costs tab already read both as one. A typed category in any
case, or a common name like Hotel or Flight, is now stored as its fixed key on
every path (REST, MCP, linked booking costs), the totals the MCP reads group by
that key, and the tools name the real keys. The client reads categories through
the same shared mapping. Any other text is still kept as given.

The journal tools said that a private entry stays off the share link. It never
did: the share link shows every entry that is not a draft, whatever its
visibility, and the web editor has no visibility at all. The tools now say so
and point to is_draft, which is what keeps an entry to the contributors.

The wiki explains why the location button needs HTTPS and how to run TREK behind
an HTTPS tunnel while still using it over plain HTTP at home, and that Android's
photo picker drops a photo's location while Immich and Synology keep it.
2026-11-04 14:26:25 +01:00
Maurice 82295a1d3f fix(planner): hold the tests and the road trip rule to the corrected morning stay
The road trip spec and wiki described the old choice of the stay a day wakes up in,
and three tests still looked for the Tour label.
2026-11-04 14:00:35 +01:00
Maurice 3c78452c14 fix(planner): start the day after a side trip at the side trip's hotel, and name the booked tour a guided tour
With a room kept for the whole trip and a second stay for a night away, the day
after the side trip took the first stay in the list as the hotel it woke up in.
The Days view then drew a drive from the kept room, often hundreds of kilometres
away, to the side trip's first stop or airport, next to the flight back. The
morning now belongs to the stay checked into last, and the evening to the stay
that runs on past the day. The map also passes a booking's own point at the edge
of the day, as the sidebar already did, so a taxi leaving from the hotel adds no
zero-length road from the hotel to itself.

The Tour booking type read like the Tours tab, and a booked food tour left people
looking at an empty Tours tab. It is now called Guided tour in every language,
the empty Tours tab says that booked tours go under Bookings, and the help
center and wiki use the new name. The stored type stays 'tour'.
2026-11-04 13:50:06 +01:00
Maurice 74864f2543 fix(places): keep the places repository at its size baseline
The insert's note now says that a longitude is stored on the real world, in
one line fewer.
2026-11-04 12:09:29 +01:00
Maurice 178df0fbe3 fix(map): keep the world-copy fix inside the CI ratchets
Moves the Leaflet map's event bridges (click, context menu, viewport, POI drop
and the longitude-frame follower) out of MapView into leafletMapBridges, and
shares one POI drop target between the Leaflet and GL maps, so both map files
shrink instead of growing. The repair migration does its arithmetic in one
UPDATE per table, and the new tests drop their non-null assertions and loops.
2026-11-04 11:37:52 +01:00
Maurice 51af575c9a fix(map): keep places and routes on one world, and start new installs with Journey and Collections
A right-click, click or drag on a repeated world copy reported the longitude it
has on that copy (274.9 for -85.1). The place was saved there, the geocoder
found nothing, and a route to a place on the real world ran across the whole
map (#2976). Longitudes are now brought back onto [-180, 180] where the map
hands them out (Leaflet and GL), where they are stored (places, booking
endpoints, collection places, so REST, MCP, plugin RPC and importers alike)
and before reverse geocoding. A migration moves the ones already stored.

Leaflet draws a layer at its literal longitude only, so a trip from Los
Angeles to Auckland had its pins at opposite ends of the world while the
flight ran off the edge to the next copy (#2975). A trip that crosses the
antimeridian is now drawn in one longitude frame, seamed in the widest
stretch it leaves empty, so pins, routes, flights and every overlay lie on
one copy and the map opens across the Pacific. Every other trip has no frame
and draws exactly as before. The GL day fit also takes the short way.

A new install now starts with Journey and Collections switched on, next to
the trip addons (all but Road trip), Atlas, Vacay and the Naver import that
already were. Existing installs keep their addons as they are.

Closes #2975
Closes #2976
2026-11-04 10:56:36 +01:00
171 changed files with 1931 additions and 418 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 380,
"@typescript-eslint/no-non-null-assertion": 377,
"@typescript-eslint/no-misused-promises": 847,
"no-console": 88,
"complexity": 218,
@@ -8,7 +8,7 @@
"trek/no-swallowed-catch": 128,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 555,
"react-hooks/refs": 554,
"react-hooks/set-state-in-effect": 193,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/exhaustive-deps": 116,
+2 -2
View File
@@ -5,9 +5,9 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapView.tsx": 1337,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/Map/MapViewGL.tsx": 2198,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
@@ -1,6 +1,6 @@
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
import { COST_CATEGORIES, costCategoryOf, type CostCategory } from '@trek/shared'
/**
* The fixed Costs categories. Users can't add their own — every expense maps to
@@ -34,34 +34,7 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
/**
* Legacy / English free-text categories (and reservation type labels) mapped to
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
* which never matched the lowercase keys and fell through to `other`.
*/
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport', cable_car: 'transport',
transport: 'transport', transportation: 'transport',
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
grocery: 'groceries', groceries: 'groceries',
activity: 'activities', activities: 'activities',
sightseeing: 'sightseeing', sights: 'sightseeing',
shop: 'shopping', shopping: 'shopping',
fee: 'fees', fees: 'fees',
health: 'health', medical: 'health',
tip: 'tips', tips: 'tips',
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
parking: 'parking', parkings: 'parking', 'car park': 'parking',
other: 'other', misc: 'other',
}
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
if (!cat) return COST_CAT_META.other
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
return COST_CAT_META[costCategoryOf(cat) ?? 'other']
}
@@ -3,6 +3,8 @@ import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../i18n';
import { frameLine, frameLng } from '../../utils/lngFrame';
import { useLngFrame } from '../Map/lngFrameContext';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
import { ferrataPositions } from './ferrataModel';
@@ -81,6 +83,8 @@ export default function FerrataLayerLeaflet({
selectedId?: string | null;
}) {
const paint = useFerrataPaint();
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame();
const iconGroups = useIconGroups(groups);
const [hovered, setHovered] = useState<string | null>(null);
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
@@ -110,7 +114,7 @@ export default function FerrataLayerLeaflet({
group.segments.map((segment) => ({
group,
segment,
positions: ferrataPositions(segment),
positions: frameLine(frame, ferrataPositions(segment)),
casing,
litCasing,
line,
@@ -118,7 +122,7 @@ export default function FerrataLayerLeaflet({
hit,
}))
);
}, [groups, paint]);
}, [groups, paint, frame]);
if (lines.length === 0) return null;
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
@@ -163,7 +167,7 @@ export default function FerrataLayerLeaflet({
{iconGroups.map((group) => (
<Marker
key={`${group.id}-icon`}
position={group.midpoint}
position={frame ? [group.midpoint[0], frameLng(frame, group.midpoint[1])] : group.midpoint}
icon={ICON}
keyboard={false}
eventHandlers={{
@@ -0,0 +1,34 @@
// FE-JRN-OPENAT-001 to FE-JRN-OPENAT-005
import { describe, expect, it } from 'vitest';
import { journeyOpenAtEntryId } from './journeyOpenAt';
const entries = [
{ id: 3, entry_date: '2026-05-03' },
{ id: 1, entry_date: '2026-05-01' },
{ id: 2, entry_date: '2026-05-01' },
{ id: 5, entry_date: '2026-05-05' },
];
describe('journeyOpenAtEntryId', () => {
it('FE-JRN-OPENAT-001: a running journey opens on the first entry of today', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03' })).toBe('3');
});
it('FE-JRN-OPENAT-002: a rest day lands on the last day written before it', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-05-04' })).toBe('3');
});
it('FE-JRN-OPENAT-003: a journey that is over, or not begun, opens at its start', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01' })).toBeNull();
expect(journeyOpenAtEntryId(entries, { today: '2026-04-01' })).toBeNull();
});
it('FE-JRN-OPENAT-004: newest-first opens on the latest day, also after the trip', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01', newestFirst: true })).toBe('5');
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03', newestFirst: true })).toBe('5');
});
it('FE-JRN-OPENAT-005: an empty journey has nothing to open on', () => {
expect(journeyOpenAtEntryId([], { today: '2026-05-03', newestFirst: true })).toBeNull();
});
});
@@ -0,0 +1,40 @@
interface DatedEntry {
id: number | string;
entry_date: string;
}
/**
* The entry a journey opens on, or null for its first one.
*
* Every load used to start at the first entry, so keeping a journal on a trip
* meant scrolling past everything already written before you could add to it
* (discussion #2299). "Today" means the last day at or before now: a rest day
* with nothing written still lands you at yesterday rather than at the start.
*
* Only while the journey is running, though. A journey that ended last spring
* is something you read, and reading starts at the beginning; opening it at the
* last page would be the worse of the two mistakes.
*
* A link the owner published newest-first reads like a blog (#1614), so it
* opens on the latest day whether or not the trip is still running (#2851).
*
* Always the first entry OF the chosen day, not the last one before now: a day
* is read from its beginning.
*/
export function journeyOpenAtEntryId(
entries: readonly DatedEntry[],
{ today, newestFirst = false }: { today: string; newestFirst?: boolean }
): string | null {
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date));
const first = dated[0];
const last = dated[dated.length - 1];
if (!first || !last) return null;
let day = last.entry_date;
if (!newestFirst) {
if (today < first.entry_date || today > last.entry_date) return null;
const reached = dated.filter((e) => e.entry_date <= today);
day = reached[reached.length - 1]?.entry_date ?? first.entry_date;
}
const dayStart = dated.find((e) => e.entry_date === day) ?? first;
return String(dayStart.id);
}
@@ -1,4 +1,4 @@
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-008: the card carousel over the journey map,
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-009: the card carousel over the journey map,
// behind both the phone journey screen and the narrow desktop and public timeline.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -145,4 +145,43 @@ describe('useJourneyCarouselSync', () => {
act(() => result.current.openEntryById('99'));
expect(props.onOpenEntry).toHaveBeenCalledTimes(1);
});
it('FE-JRN-CAROUSEL-009: a carousel scrolled to either end settles on the card at that end (#2851)', () => {
const carousel = document.createElement('div');
carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
Object.defineProperty(carousel, 'clientWidth', { value: 300 });
Object.defineProperty(carousel, 'scrollWidth', { value: 600 });
const hook = renderHook(
(p: Options) => {
const sync = useJourneyCarouselSync(p);
(sync.carouselRef as { current: HTMLDivElement | null }).current = carousel;
return sync;
},
{ initialProps: { entries: [], mapEntries: [], onOpenEntry: vi.fn() } }
);
hook.rerender({ entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn() });
// Card 1 sits at the centre (150) in both positions: only the ends decide.
[0, 100, 200].forEach((left, i) => {
const node = document.createElement('div');
node.getBoundingClientRect = () => ({ left, width: 100 }) as DOMRect;
hook.result.current.cardRefs.current.set(i, node);
});
const settleAt = (scrollLeft: number) => {
Object.defineProperty(carousel, 'scrollLeft', { value: scrollLeft, configurable: true });
carousel.dispatchEvent(new Event('scroll'));
act(() => {
vi.advanceTimersByTime(150);
});
};
settleAt(300);
expect(hook.result.current.activeIndex).toBe(2);
settleAt(150);
expect(hook.result.current.activeIndex).toBe(1);
// Right to left: the scroll position runs negative from the start.
settleAt(-300);
expect(hook.result.current.activeIndex).toBe(2);
settleAt(0);
expect(hook.result.current.activeIndex).toBe(0);
});
});
@@ -71,6 +71,14 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
bestIdx = idx;
}
});
// The first and the last card can never reach the centre, so a carousel scrolled
// as far as it goes means the card at that end: without this, opening on the
// latest day settled on the one before it (#2851). Measured from the start in
// either direction: in a right-to-left layout scrollLeft runs negative.
const travel = el.scrollWidth - el.clientWidth;
const scrolled = Math.abs(el.scrollLeft);
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
setActiveIndex((prev) => {
if (prev !== bestIdx) syncMapToCard(bestIdx);
return bestIdx;
@@ -1,8 +1,10 @@
import L from 'leaflet';
import { useCallback, useEffect } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { MAX_ZOOM_RASTER, type ViewportPadding } from '../../utils/mapViewport';
import { ASSISTANT_CAMERA_MS, centreOf, coverPadding, useCameraRequest, useUserMoveClock } from './assistantCamera';
import { useLngFrame } from './lngFrameContext';
import type { AssistantCamera } from './mapViewContract';
interface AssistantCameraControllerProps {
@@ -18,12 +20,17 @@ interface AssistantCameraControllerProps {
*/
export function AssistantCameraController({ camera, padding }: AssistantCameraControllerProps) {
const map = useMap();
// The points are framed the way the map draws them (#2975).
const frame = useLngFrame();
const getContainer = useCallback(() => map.getContainer(), [map]);
const userMovedAt = useUserMoveClock(getContainer, true);
const request = useCameraRequest(camera, userMovedAt);
useEffect(() => {
request((next) => {
request((camera) => {
const next = frame
? { ...camera, points: camera.points.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) }
: camera;
const duration = ASSISTANT_CAMERA_MS / 1000;
if (next.mode === 'pan') {
const centre = centreOf(next.points);
@@ -39,7 +46,7 @@ export function AssistantCameraController({ camera, padding }: AssistantCameraCo
duration,
});
});
}, [map, padding, request]);
}, [map, padding, request, frame]);
return null;
}
@@ -1,7 +1,9 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { GHOST_SIZE, mountGhosts, setGhostHot, type GhostHandlers } from './assistantGhostMarker';
import { useLngFrame } from './lngFrameContext';
import type { AssistantGhost } from './mapViewContract';
interface AssistantGhostLayerProps {
@@ -22,6 +24,8 @@ const GHOST_Z_OFFSET = 900;
*/
export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: AssistantGhostLayerProps) {
const map = useMap();
// Pinned where the map draws the trip, in its longitude frame (#2975).
const frame = useLngFrame();
const handlers = useRef<GhostHandlers>({ onHover, onClick });
useLayoutEffect(() => {
handlers.current = { onHover, onClick };
@@ -36,7 +40,11 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
const icon = L.divIcon({ html: el, className: 'trek-ghost-pin', iconSize: [GHOST_SIZE, GHOST_SIZE] });
const label = document.createElement('span');
label.textContent = ghost.name;
const marker = L.marker([ghost.lat, ghost.lng], { icon, keyboard: false, zIndexOffset: GHOST_Z_OFFSET })
const marker = L.marker([ghost.lat, frameLng(frame, ghost.lng)], {
icon,
keyboard: false,
zIndexOffset: GHOST_Z_OFFSET,
})
.bindTooltip(label, { direction: 'top', offset: [0, -GHOST_SIZE / 2], className: 'map-tooltip' })
.addTo(map);
return { remove: () => marker.remove() };
@@ -48,7 +56,7 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
mounted.dispose();
elements.current = new Map();
};
}, [ghosts, map]);
}, [ghosts, map, frame]);
useEffect(() => {
setGhostHot(elements.current, hoveredId);
@@ -1,8 +1,11 @@
import type { FeatureGroup as LeafletFeatureGroup, LeafletMouseEvent } from 'leaflet';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { CircleMarker, FeatureGroup, Polyline, Tooltip } from 'react-leaflet';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { frameLine, frameLng, type LngFrame } from '../../utils/lngFrame';
import { onRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
/** A rim under the line, the line, and the invisible fat line that takes the pointer. */
const CASING_WEIGHT = 7;
@@ -38,7 +41,10 @@ function GhostLine({ line, onHover, onPoint }: { line: AssistantRouteLine } & Li
if (line.hot) group.current?.bringToFront();
}, [line.hot]);
const opacity = line.dim ? DIM_OPACITY : 1;
const point = (event: LeafletMouseEvent) => onPoint?.(line.ref, event.latlng.lat, event.latlng.lng);
const point = (event: LeafletMouseEvent) => {
const at = onRealWorld(event.latlng);
onPoint?.(line.ref, at.lat, at.lng);
};
return (
<FeatureGroup ref={group}>
<Polyline
@@ -122,11 +128,25 @@ interface AssistantGhostLineLayerProps extends LineHandlers {
* under its pins. The twin of
* useAssistantGhostLinesGL.
*/
/** A line drawn in the map's longitude frame, with the trip it proposes changes to (#2975). */
function inFrame(frame: LngFrame, line: AssistantRouteLine): AssistantRouteLine {
const at = ([lat, lng]: [number, number]): [number, number] => [lat, frameLng(frame, lng)];
return {
...line,
lines: line.lines.map((part) => frameLine(frame, part)),
ends: line.ends && { start: at(line.ends.start), end: line.ends.end && at(line.ends.end) },
stops: line.stops?.map((stop) => ({ ...stop, lng: frameLng(frame, stop.lng) })),
tints: line.tints?.map((tint) => ({ ...tint, line: frameLine(frame, tint.line) })),
};
}
export function AssistantGhostLineLayer({ lines, onHover, onPoint }: AssistantGhostLineLayerProps) {
if (!lines?.length) return null;
const frame = useLngFrame();
const drawn = useMemo(() => (frame && lines ? lines.map((line) => inFrame(frame, line)) : lines), [frame, lines]);
if (!drawn?.length) return null;
return (
<>
{lines.map((line) => (
{drawn.map((line) => (
<GhostLine key={line.id} line={line} onHover={onHover} onPoint={onPoint} />
))}
</>
+8 -4
View File
@@ -4,11 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
}) {
const map = useMap()
const frame = useLngFrame()
const { t } = useTranslation()
const [zoom, setZoom] = useState(() => map.getZoom())
const popup = useRef<L.Popup | null>(null)
@@ -18,20 +21,21 @@ export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng]), zoom) : [], [pois, enabled, map, zoom])
// Measured and placed where the map draws them, in its longitude frame (#2975).
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, frameLng(frame, poi.lng)]), zoom) : [], [pois, enabled, map, zoom, frame])
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
if (!enabled) return <>{children}</>
return <>{groups.map(group => {
if (group.pois.length === 1) return markers.get(group.pois[0].osm_id)
const label = t('roadtrip.poi.found', { count: group.pois.length })
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, group.lng]} alt={label}
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, frameLng(frame, group.lng)]} alt={label}
icon={L.divIcon({ className: '', html: poiClusterMarkup(group.pois.length), iconSize: [38, 38], iconAnchor: [19, 19] })}
zIndexOffset={600} eventHandlers={{ click: () => {
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
popup.current?.remove()
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, group.lng])
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, frameLng(frame, group.lng)])
.setContent(poiClusterList(group.pois, poi => { popup.current?.remove(); onPoiClick?.(poi) })).openOn(map)
} else map.fitBounds(group.pois.map(poi => [poi.lat, poi.lng] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} else map.fitBounds(group.pois.map(poi => [poi.lat, frameLng(frame, poi.lng)] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} }} />
})}</>
}
@@ -1,5 +1,7 @@
import { useMemo } from 'react'
import { Polyline } from 'react-leaflet'
import { useLngFrame } from './lngFrameContext'
import { frameLine } from '../../utils/lngFrame'
import type { DawarichTrack } from '@trek/shared'
import {
DAWARICH_TRAIL_CASING,
@@ -37,7 +39,10 @@ export default function DawarichTrailLayer({
/** The `trek-track-casing` pane, when the renderer supports panes. */
casingPane?: string
}) {
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates), [track, selectedDate, hiddenDates])
const frame = useLngFrame()
// Drawn in the map's longitude frame, with the trip it was recorded on (#2975).
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates)
.map(segment => (frame ? { ...segment, points: frameLine(frame, segment.points) } : segment)), [track, selectedDate, hiddenDates, frame])
if (segments.length === 0) return null
return (
+5 -1
View File
@@ -4,10 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { hazardPopup, hazardFeature } from './hazardPopup'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) {
const { t } = useTranslation()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
const frame = useLngFrame()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}-${frame?.seam ?? ''}`} data={hazardFeature(hazard)}
coordsToLatLng={coords => L.latLng(coords[1], frameLng(frame, coords[0]))}
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
pointToLayer={(_, point) => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })}
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip trek-hazard-popup', maxWidth: 320 })}
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { Circle, Polygon, Polyline, Tooltip, useMap } from 'react-leaflet'
import { pluginsApi, type PluginMapLayer, type PluginMapLayerFeature } from '../../api/client'
import { useLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
/**
* Host-rendered overlay for the `mapLayerProvider` plugin hook. A plugin returns
@@ -47,6 +49,7 @@ function pathOptions(f: PluginMapLayerFeature, pane: string | undefined) {
export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
const map = useMap()
const frame = useLngFrame()
const [layers, setLayers] = useState<PluginMapLayer[]>([])
// Whether our dedicated below-overlay pane exists. Pane support is a Leaflet
// optimization — if the map has no pane API (e.g. a minimal renderer), the
@@ -73,6 +76,7 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
if (layers.length === 0) return null
const pane = hasPane ? PANE : undefined
// Drawn in the map's longitude frame, with the trip (#2975).
return (
<>
@@ -80,13 +84,13 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
const key = `${layer.pluginId}:${layer.id}:${i}`
const tooltip = f.label ? <Tooltip sticky>{f.label}</Tooltip> : null
if (f.type === 'polyline' && f.points) {
return <Polyline key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
return <Polyline key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
}
if (f.type === 'polygon' && f.points) {
return <Polygon key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
return <Polygon key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
}
if (f.type === 'circle' && f.center && f.radiusM) {
return <Circle key={key} center={f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
return <Circle key={key} center={frame ? [f.center[0], frameLng(frame, f.center[1])] : f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
}
return null
}))}
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'
import { Marker, Popup } from 'react-leaflet'
import L from 'leaflet'
import { pluginsApi, type PluginMapMarker } from '../../api/client'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
/**
* Host-rendered overlay for the `mapMarkerProvider` plugin hook (#587). A plugin
@@ -33,6 +35,8 @@ function markerIcon(tone: PluginMapMarker['tone']): L.DivIcon {
export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
const [markers, setMarkers] = useState<PluginMapMarker[]>([])
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame()
useEffect(() => {
if (tripId == null) { setMarkers([]); return }
@@ -54,7 +58,7 @@ export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
return (
<>
{markers.map(mk => (
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, mk.lng]} icon={icons.get(mk.tone)!}>
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, frameLng(frame, mk.lng)]} icon={icons.get(mk.tone)!}>
{(mk.label || mk.popupText || mk.url) && (
<Popup>
<div style={{ minWidth: 120, fontSize: 13 }}>
+13 -4
View File
@@ -1185,9 +1185,14 @@ describe('MapView map event wiring', () => {
it('FE-COMP-MAPVIEW-046: a map-click handler is attached and detached again', () => {
const onMapClick = vi.fn()
const { unmount } = render(<MapView onMapClick={onMapClick} />)
expect(mapMock.on).toHaveBeenCalledWith('click', onMapClick)
const bound = mapMock.on.mock.calls.find(c => c[0] === 'click')?.[1]
expect(bound).toEqual(expect.any(Function))
// A click on the real world reaches the caller as the very event the map fired.
const event = { latlng: { lat: 48, lng: 2 } }
bound(event)
expect(onMapClick).toHaveBeenCalledWith(event)
unmount()
expect(mapMock.off).toHaveBeenCalledWith('click', onMapClick)
expect(mapMock.off).toHaveBeenCalledWith('click', bound)
})
it('FE-COMP-MAPVIEW-047: without a click handler nothing is bound', () => {
@@ -1198,9 +1203,13 @@ describe('MapView map event wiring', () => {
it('FE-COMP-MAPVIEW-048: a context-menu handler is attached and detached again', () => {
const onMapContextMenu = vi.fn()
const { unmount } = render(<MapView onMapContextMenu={onMapContextMenu} />)
expect(mapMock.on).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
const bound = mapMock.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1]
expect(bound).toEqual(expect.any(Function))
const event = { latlng: { lat: 48, lng: 2 } }
bound(event)
expect(onMapContextMenu).toHaveBeenCalledWith(event)
unmount()
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', bound)
})
it('FE-COMP-MAPVIEW-049: changing the center prop moves the camera there', () => {
+46 -99
View File
@@ -3,7 +3,7 @@ import DOM from 'react-dom'
import { categoryIconSvg } from './categoryIconSvg'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker'
@@ -121,7 +121,8 @@ function RouteViaMarker({ via, controls, eventHandlers, children }: {
via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode
}) {
const [marker, setMarker] = useState<L.Marker | null>(null)
return <Marker ref={setMarker} position={[via.lat, via.lng]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
const frame = useLngFrame()
return <Marker ref={setMarker} position={[via.lat, frameLng(frame, via.lng)]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
alt={via.nightPause ? via.label : undefined} zIndexOffset={800} eventHandlers={eventHandlers}>
{children}
{via.nightPause && <NightPauseDrag marker={marker} via={via} controls={controls} />}
@@ -298,59 +299,6 @@ function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { cur
return null
}
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
const map = useMap()
useEffect(() => {
if (!onPoiDropOnRoute) return
const container = map.getContainer()
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
}, [map, onPoiDropOnRoute])
return null
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
const map = useMap()
useEffect(() => {
if (!onViewportChange) return
const emit = () => {
const b = map.getBounds()
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }, map.getZoom())
}
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit)
map.on('zoomend', emit)
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
}, [map, onViewportChange])
return null
}
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
const map = useMap()
useEffect(() => {
@@ -377,6 +325,7 @@ interface SelectionControllerProps {
function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prev = useRef(null)
useEffect(() => {
@@ -387,7 +336,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, selected.lng]
const latlng: [number, number] = [selected.lat, frameLng(frame, selected.lng)]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
@@ -399,7 +348,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow])
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow, frame])
return null
}
@@ -411,14 +360,15 @@ interface MapControllerProps {
function MapController({ center, zoom }: MapControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prevCenter = useRef(center)
useEffect(() => {
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
map.setView(center, zoom)
map.setView([center[0], frameLng(frame, center[1])], zoom)
prevCenter.current = center
}
}, [center, zoom, map])
}, [center, zoom, map, frame])
return null
}
@@ -456,10 +406,12 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
const prevFocusKey = useRef(focusKey)
const built = useRef(false)
const frame = useLngFrame()
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
if (coords.length === 0) return
try {
const bounds = L.latLngBounds(coords)
// In the map's longitude frame, so a trip across the Pacific is framed across the Pacific (#2975).
const bounds = L.latLngBounds(frame ? coords.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) : coords)
if (bounds.isValid()) {
/*
* The padding already reserves the day panel's height at the bottom
@@ -478,7 +430,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
map.fitBounds(bounds, { maxZoom: 16, animate: true, ...padding })
}
} catch {}
}, [map, paddingOpts])
}, [map, paddingOpts, frame])
// New fitKey (initial trip fit or a day selection): fit to the destinations now
// and arm a one-shot re-fit for when the route arrives.
@@ -535,10 +487,6 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
return null
}
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null
}
const TRACK_CASING_PANE = 'trek-track-casing'
/**
@@ -561,26 +509,6 @@ function TrackCasingPane({ onReady }: { onReady: (ready: boolean) => void }) {
return null
}
function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap()
useEffect(() => {
if (!onClick) return
map.on('click', onClick)
return () => { map.off('click', onClick) }
}, [map, onClick])
return null
}
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap()
useEffect(() => {
if (!onContextMenu) return
map.on('contextmenu', onContextMenu)
return () => { map.off('contextmenu', onContextMenu) }
}, [map, onContextMenu])
return null
}
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
import { useMarkerThumbs } from './useMarkerThumbs'
@@ -594,9 +522,15 @@ import { useIsPhone } from '../../mobile/useIsPhone'
// identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { LngFrameProvider, useLngFrame, useTripLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
import { LngFrameFollower, MapClickHandler, MapContextMenuHandler, PoiDropTarget, ViewportController } from './leafletMapBridges'
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
function LeafletLocationLayer({ position: fix, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
const map = useMap()
const frame = useLngFrame()
// Drawn on the trip's world copy (#2975); every field but the longitude is the fix itself.
const position = useMemo(() => (fix && frame ? { ...fix, lng: frameLng(frame, fix.lng) } : fix), [fix, frame])
// When the user is in follow mode, keep the map centred on the dot.
// setView (no animation) is what Google Maps does during navigation —
@@ -711,7 +645,8 @@ const MemoMarker = memo(function MemoMarker({
// differs, and the cluster group answers a moved child by taking it out and putting
// it back — which collapses an open fan. A fresh array literal here did that on
// every render the marker took part in.
const position = useMemo<[number, number]>(() => [place.lat, place.lng], [place.lat, place.lng])
const frame = useLngFrame()
const position = useMemo<[number, number]>(() => [place.lat, frameLng(frame, place.lng)], [place.lat, place.lng, frame])
const register = useCallback((marker: unknown) => { onRegister(place.id, marker) }, [onRegister, place.id])
return (
<Marker
@@ -835,11 +770,15 @@ export const MapView = memo(function MapView({
*/
const isGcjBasemap = !tourRaster && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
// Leaflet draws a layer on one world copy only, so a trip across the antimeridian is
// drawn in a longitude frame that keeps it on one copy (#2975). Null for every other
// trip, and then each layer below draws exactly what it always drew.
const lngFrame = useTripLngFrame(places, dayPlaces, reservations)
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, poi.lng]}
position={[poi.lat, frameLng(lngFrame, poi.lng)]}
icon={createPoiIcon(poi, poi.brand_wikidata)}
zIndexOffset={500}
eventHandlers={{
@@ -858,7 +797,7 @@ export const MapView = memo(function MapView({
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip"><PoiTooltipBody poi={poi} /></Tooltip>
</Marker>
)), [pois, onPoiClick, onPoiDropOnRoute])
)), [pois, onPoiClick, onPoiDropOnRoute, lngFrame])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
@@ -892,8 +831,11 @@ export const MapView = memo(function MapView({
const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, {
tileSize: TILE_SIZE_RASTER,
padding: paddingBox,
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
wrapsWorld: lngFrame !== null,
})
return { center: framed?.center ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
const opening: [number, number] | undefined = framed ? [framed.center[0], frameLng(lngFrame, framed.center[1])] : undefined
return { center: opening ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
})
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
@@ -947,7 +889,8 @@ export const MapView = memo(function MapView({
// Flattened [lat,lng] points of the selected day's route, so the bounds fit can
// include the full polyline once it has been computed.
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route])
const framedRoute = useMemo(() => (lngFrame && route ? route.map(seg => frameLine(lngFrame, seg)) : route), [route, lngFrame])
const routeCoords = useMemo<[number, number][]>(() => (framedRoute || []).flat() as [number, number][], [framedRoute])
const photoUrls = useMarkerThumbs(places)
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
@@ -1015,9 +958,9 @@ export const MapView = memo(function MapView({
const gpxTracks = useMemo(() => places.flatMap(place => {
const geometry = parseRenderableRouteGeometry(place.route_geometry)
if (!geometry) return []
const coords: [number, number][] = geometry.map(([lat, lng]) => [lat, lng])
const coords: [number, number][] = frameLine(lngFrame, geometry.map(([lat, lng]) => [lat, lng] as [number, number]))
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
}), [places])
}), [places, lngFrame])
// Keeps the click handler out of the track lines' memo (TrackLines): `handleMarkerClick`
// changes on every selection, and depending on it would redraw all tracks.
@@ -1053,6 +996,7 @@ export const MapView = memo(function MapView({
return (
<>
<div className="w-full h-full relative">
<LngFrameProvider value={lngFrame}>
<MapContainer
// The datum is in the element's identity, because react-leaflet builds the
// map once and Leaflet cannot change a CRS afterwards. Settings can arrive
@@ -1125,6 +1069,8 @@ export const MapView = memo(function MapView({
/>
)}
{/* First, so a fit or a camera request in the same commit starts from the trip's world copy. */}
<LngFrameFollower />
<MapController center={center} zoom={zoom} />
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
@@ -1142,7 +1088,7 @@ export const MapView = memo(function MapView({
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
When the route can be reshaped an invisible band wider than both takes the click. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
{framedRoute && framedRoute.length > 0 && framedRoute.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
<Polyline
key={`${i}-walk`}
@@ -1174,7 +1120,7 @@ export const MapView = memo(function MapView({
{(accessLines ?? []).map((spur, i) => (
<Polyline
key={`access-${i}`}
positions={spur.line}
positions={frameLine(lngFrame, spur.line)}
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
interactive={false}
/>
@@ -1190,13 +1136,13 @@ export const MapView = memo(function MapView({
return [
<Polyline
key={`alt-${alt.index}-casing`}
positions={alt.coordinates}
positions={frameLine(lngFrame, alt.coordinates)}
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
<Polyline
key={`alt-${alt.index}`}
positions={alt.coordinates}
positions={frameLine(lngFrame, alt.coordinates)}
pathOptions={{
color: alt.color,
weight: active ? 6 : 4,
@@ -1213,7 +1159,7 @@ export const MapView = memo(function MapView({
// The drive time on the road itself, the way Apple labels them.
<Marker
key={`alt-${alt.index}-label`}
position={[alt.at.lat, alt.at.lng]}
position={[alt.at.lat, frameLng(lngFrame, alt.at.lng)]}
zIndexOffset={600}
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
eventHandlers={onChooseAlternative ? {
@@ -1249,7 +1195,7 @@ export const MapView = memo(function MapView({
{routeProfileFocus && (
<CircleMarker
center={[routeProfileFocus.lat, routeProfileFocus.lng]}
center={[routeProfileFocus.lat, frameLng(lngFrame, routeProfileFocus.lng)]}
radius={8}
pathOptions={{ className: 'tour-profile-focus-marker', color: 'var(--bg-card)', weight: 3, fillColor: 'var(--text-muted)', fillOpacity: 1 }}
interactive={false}
@@ -1301,6 +1247,7 @@ export const MapView = memo(function MapView({
<AssistantMapLeaflet assistant={assistant} padding={paddingBox} markerEl={assistantMarkerEl} />
<PluginMapLayers tripId={tripId} />
</MapContainer>
</LngFrameProvider>
{isMobile && <LocationButton
mode={trackingMode}
error={trackingError}
+11 -31
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useMemo, useState, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { useStableVias } from './viaMarkerState'
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL'
import { carryAssistantMarks, useAssistantMapGL } from './useAssistantMapGL'
@@ -49,6 +49,8 @@ import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { onRealWorld } from '../../utils/worldCopy'
import { bindPoiDropTarget } from './poiDropTarget'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
@@ -725,7 +727,7 @@ export function MapViewGL({
return
}
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
const at = onRealWorld(map.unproject([e.clientX - rect.left, e.clientY - rect.top]))
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
}
// The browser fires this when the captured element is torn out of the DOM,
@@ -925,7 +927,8 @@ export function MapViewGL({
// trip reloads for a gesture the user made once. The picker is a modal
// choice about that leg; dropping a via mid-choice is not offered anywhere.
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
onRouteClick(e.lngLat.lat, e.lngLat.lng)
const at = onRealWorld(e.lngLat)
onRouteClick(at.lat, at.lng)
}
const enter = () => { map.getCanvas().style.cursor = 'copy' }
const leave = () => { map.getCanvas().style.cursor = '' }
@@ -944,35 +947,12 @@ export function MapViewGL({
}
}, [mapReady, onRouteClick])
/**
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
* canvas and has no element to aim at. The coordinate under the pointer says just as
* much once it is projected onto the routed geometry, and it works the same in all
* three renderers.
*/
// A POI dropped on the map lands on the drive (see poiDropTarget.ts).
useEffect(() => {
const map = mapRef.current
const container = map?.getContainer?.()
if (!container || !mapReady || !onPoiDropOnRoute) return
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
return bindPoiDropTarget(container, (x, y) => map.unproject([x, y]), onPoiDropOnRoute)
}, [mapReady, onPoiDropOnRoute])
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
@@ -1343,7 +1323,7 @@ export function MapViewGL({
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
) return
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
onClickRefs.current.map?.({ latlng: onRealWorld({ lat: e.lngLat.lat, lng: e.lngLat.lng }) })
})
// Emit the viewport (pan/zoom + once on first idle) for the POI-explore pill.
const emitViewport = () => reportGlViewport(map, onViewportChangeRef.current)
@@ -1376,7 +1356,7 @@ export function MapViewGL({
// timer — dedupe so the form doesn't open twice.
if (Date.now() - lastContextFire < 700) return false
lastContextFire = Date.now()
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
onClickRefs.current.context?.({ latlng: onRealWorld({ lat: lngLat.lat, lng: lngLat.lng }), originalEvent })
return true
}
// MapLibre swallows the map contextmenu at the end of a right-button
@@ -1982,7 +1962,7 @@ export function MapViewGL({
const fitPoints = routeCoords.length > 0 ? [...routeCoords, ...markerPoints] : markerPoints
if (fitPoints.length === 0) return
const bounds = new gl.LngLatBounds()
fitPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
alongShortestArc(fitPoints).forEach(([lat, lng]) => bounds.extend([lng, lat]))
let fitted = false
const run = () => {
try {
+7 -3
View File
@@ -3,25 +3,29 @@ import { useMap } from 'react-leaflet'
import type L from 'leaflet'
import type { RouteVia } from '../../types'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export function NightPauseDrag({ marker, via, controls }: { marker: L.Marker | null; via: RouteVia; controls?: DayBoundaryControls }) {
const map = useMap()
// The route is drawn in the map's longitude frame (#2975), so its points are measured there too.
const frame = useLngFrame()
useEffect(() => {
const el = marker?.getElement()
if (!marker || !el || !controls) return
return bindDayBoundaryDrag(el, via, controls, {
project: (lat, lng) => {
const point = map.latLngToContainerPoint([lat, lng])
const point = map.latLngToContainerPoint([lat, frameLng(frame, lng)])
const rect = map.getContainer().getBoundingClientRect()
return { x: point.x + rect.left, y: point.y + rect.top }
},
setPosition: (lat, lng) => { marker.setLatLng([lat, lng]) },
setPosition: (lat, lng) => { marker.setLatLng([lat, frameLng(frame, lng)]) },
lock: () => {
const enabled = map.dragging.enabled()
map.dragging.disable()
return () => { if (enabled) map.dragging.enable() }
},
})
}, [map, marker, via, controls])
}, [map, marker, via, controls, frame])
return null
}
@@ -1,6 +1,9 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { onRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { plannerPinBadge, plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
@@ -45,7 +48,11 @@ export default function PlannerWaypointMarker({
useLayoutEffect(() => {
handlersRef.current = { onClick, onMove };
}, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const frame = useLngFrame();
const position = useMemo(
() => [point.lat, frameLng(frame, point.lng)] as [number, number],
[point.lat, point.lng, frame]
);
const { label, shaping, stageEnd } = point;
const icon = useMemo(
() => plannerWaypointIcon(pinCss, { label, shaping, stageEnd }, index, selected, !!onMove),
@@ -67,7 +74,7 @@ export default function PlannerWaypointMarker({
if (!element) return;
return wirePlannerWaypointDrag({
element,
initial: { lat: point.lat, lng: point.lng },
initial: { lat: position[0], lng: position[1] },
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect();
const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top]);
@@ -76,9 +83,13 @@ export default function PlannerWaypointMarker({
setPosition: ({ lat, lng }) => {
marker.setLatLng([lat, lng]);
},
onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false,
// Saved on the real world, whichever copy of it the pin was dragged on (#2976).
onCommit: (at) => {
const { lat, lng } = onRealWorld(at);
return handlersRef.current.onMove?.(point.id, lat, lng) ?? false;
},
});
}, [map, marker, onMove, point.id, point.lat, point.lng]);
}, [map, marker, onMove, point.id, position]);
return (
<Marker
@@ -9,6 +9,8 @@ import { useSettingsStore } from '../../store/settingsStore'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import { TRANSPORT_COLOR, TRANSPORT_META, transportHop, type TransportHop, type TransportType } from './transportHops'
import type { Reservation } from '../../types'
import { frameLine } from '../../utils/lngFrame'
import { useLngFrame } from './lngFrameContext'
const ENDPOINT_PANE = 'reservation-endpoints'
@@ -48,11 +50,13 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
const road = roadRoutes?.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
return road && road.length >= 2 ? [frameLine(item.frame, road)] : item.arcs
}
interface TransportItem extends TransportHop {
transitSegs: TransitMapSegment[]
/** The map's longitude frame the hop was drawn in (#2975), for the road route that arrives later. */
frame: ReturnType<typeof useLngFrame>
}
interface Props {
@@ -75,16 +79,19 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
zoomend: () => setZoom(map.getZoom()),
})
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
const frame = useLngFrame()
const items = useMemo<TransportItem[]>(() => {
const out: TransportItem[] = []
for (const r of reservations) {
const hop = transportHop(r, true)
const hop = transportHop(r, true, frame)
if (!hop) continue
out.push({ ...hop, transitSegs: hop.type === 'transit' ? getTransitMapSegments(r) : [] })
const segs = hop.type === 'transit' ? getTransitMapSegments(r) : []
const transitSegs = frame ? segs.map(seg => ({ ...seg, coords: frameLine(frame, seg.coords) })) : segs
out.push({ ...hop, transitSegs, frame })
}
return out
}, [reservations])
}, [reservations, frame])
const visibleItems = useMemo(() => {
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
@@ -3,6 +3,9 @@ import { Marker, Tooltip, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { useStableVias } from './viaMarkerState'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
import { onRealWorld } from '../../utils/worldCopy'
import type { RoadtripVia } from '@trek/shared'
/**
@@ -64,12 +67,14 @@ export default function RoadtripViaMarkers({
return () => { map.off('zoomend', update) }
}, [map])
const frame = useLngFrame()
const markers = useMemo(() => vias.map(via => ({
via,
position: [via.lat, via.lng] as [number, number],
position: [via.lat, frameLng(frame, via.lng)] as [number, number],
handlers: {
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
const at = e.target.getLatLng()
// Saved on the real world, whichever copy of it the handle was dragged on (#2976).
const at = onRealWorld(e.target.getLatLng())
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
},
// Right-click rather than a delete handle: a 12px dot has no room for one,
@@ -79,7 +84,7 @@ export default function RoadtripViaMarkers({
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
},
},
})), [vias])
})), [vias, frame])
if (!markers.length || !zoomedIn) return null
+4 -1
View File
@@ -1,4 +1,5 @@
import { Polyline } from 'react-leaflet';
import { onRealWorld } from '../../utils/worldCopy';
/**
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
@@ -31,7 +32,9 @@ export function RouteHitBand({
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
e.originalEvent.stopPropagation();
onRouteClick(e.latlng.lat, e.latlng.lng);
// The via is saved on the real world, whichever copy of it the route was clicked on (#2976).
const at = onRealWorld(e.latlng);
onRouteClick(at.lat, at.lng);
},
}}
/>
+11 -2
View File
@@ -4,6 +4,7 @@ import { useMap } from 'react-leaflet';
import { useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import { bboxOnRealWorld } from '../../utils/worldCopy';
/**
* The part of the planner's map on screen, told to the assistant after every
@@ -54,7 +55,11 @@ export function useReportViewLeaflet(): void {
if (tripId === null) return;
const read = () => {
const b = map.getBounds();
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
report(
tripId,
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
);
};
read();
map.on('moveend', read);
@@ -87,7 +92,11 @@ export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady:
const read = () => {
const b = getBounds();
if (typeof b?.getSouth !== 'function') return;
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
report(
tripId,
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
);
};
read();
on('moveend', read);
@@ -169,6 +169,45 @@ describe('buildDayRouteRuns', () => {
expect(hotelBookendOf(run[0], run[1])).toBe('morning')
})
it("FE-MAP-DRP-017: the morning after a night away starts at the side trip's hotel, not the room kept at home", () => {
// The first hotel stays booked over the whole trip and comes first in the list; the
// side trip's hotel holds the night before. The day used to start with a 716 km drive
// from the kept room to the side trip's first stop.
const days = [1, 2, 3, 4, 5].map(n => buildDay({ id: n, day_number: n }))
const kept = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 5 } as unknown as Accommodation
const away = { id: 2, trip_id: 1, place_lat: 18.7883, place_lng: 98.9853, start_day_id: 2, end_day_id: 3 } as unknown as Accommodation
const runs = buildDayRouteRuns(3, inputs({
days,
assignments: { '3': [buildAssignment({ day_id: 3, order_index: 0, place: buildPlace({ lat: 18.8048, lng: 98.9216, place_time: '10:00' }) })] },
accommodations: [kept, away],
optimizeFromAccommodation: true,
}))
expect(runs[0][0]).toMatchObject({ lat: 18.7883, lng: 98.9853, hotel: 'morning' })
expect(runs.flat().some(p => p.hotel === 'morning' && p.lat === 13.7466)).toBe(false)
})
it('FE-MAP-DRP-018: a taxi that leaves from the hotel adds no road from the hotel to itself', () => {
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
const hotel = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation
const taxi = {
id: 9, trip_id: 1, type: 'taxi', title: 'Taxi', status: 'confirmed', day_id: 2, end_day_id: 2,
reservation_time: '2026-12-02T09:00', reservation_end_time: '2026-12-02T09:40',
endpoints: [
{ role: 'from', sequence: 0, name: 'Hotel', lat: 13.7466, lng: 100.5393 },
{ role: 'to', sequence: 1, name: 'Museum', lat: 13.7516, lng: 100.4927 },
],
} as unknown as Reservation
const runs = buildDayRouteRuns(2, inputs({
days,
assignments: { '2': [buildAssignment({ day_id: 2, order_index: 0, place: buildPlace({ lat: 13.7563, lng: 100.5018, place_time: '12:00' }) })] },
reservations: [taxi],
accommodations: [hotel],
optimizeFromAccommodation: true,
}))
const zeroLength = runs.filter(run => run.length === 2 && run[0].lat === run[1].lat && run[0].lng === run[1].lng)
expect(zeroLength).toEqual([])
})
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
})
+4 -1
View File
@@ -196,7 +196,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
const role: CarrierEdge = side === 'first'
? (e.from ? 'departure' : 'arrival')
: (e.to ? 'arrival' : 'departure')
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
// The booking's own point at this edge of the day, as the sidebar passes it: a taxi
// that starts or ends at the hotel then adds no zero-length leg to it.
const point = side === 'first' ? (e.from ?? e.to) : (e.to ?? e.from)
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null, lat: point?.lat, lng: point?.lng }
}
// A carrier with a located endpoint today means the day changes geography by
// air/rail/sea — the check-in hotel is then the day's destination and the
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { greatCircle, unwrapLngs, geodesicArcs } from './flightGeodesy'
import { tripLngFrame } from '../../utils/lngFrame'
const YVR: [number, number] = [49.19, -123.18]
const ICN: [number, number] = [37.46, 126.44]
@@ -61,3 +62,29 @@ describe('flightGeodesy (#1411)', () => {
expect(unwrapLngs(pts)).toEqual(pts)
})
})
describe('flightGeodesy in a longitude frame (#2975)', () => {
const LAX: [number, number] = [33.94, -118.41]
const AKL: [number, number] = [-37.01, 174.79]
const frame = tripLngFrame([LAX[1], AKL[1]])
it('draws LAX to Auckland as one arc on one world copy, between the two pins as the frame places them', () => {
expect(frame).not.toBeNull()
const arcs = geodesicArcs([LAX[0], LAX[1] + 360], AKL, true, frame)
expect(arcs).toHaveLength(1)
const [arc] = arcs
expect(maxConsecutiveDeltaLng(arc)).toBeLessThan(180)
expect(arc[0][1]).toBeCloseTo(LAX[1] + 360, 5)
expect(arc[arc.length - 1][1]).toBeCloseTo(AKL[1], 5)
})
it('still copies an arc that leaves the frame, so both of its ends keep a line', () => {
const arcs = geodesicArcs(FRA, JFK, true, { seam: -30 })
expect(arcs).toHaveLength(2)
expect(arcs[1][0][1]).toBeCloseTo(arcs[0][0][1] + 360, 10)
})
it('without a frame draws what it drew before', () => {
expect(geodesicArcs(YVR, ICN, true, null)).toEqual(geodesicArcs(YVR, ICN, true))
})
})
+12 -4
View File
@@ -1,6 +1,8 @@
// Great-circle geometry for transport routes (flights, cruises, ferries),
// shared by the Leaflet and Mapbox/MapLibre renderers (#1411).
import { frameLine, type LngFrame } from '../../utils/lngFrame'
const toRad = (d: number) => d * Math.PI / 180
const toDeg = (r: number) => r * 180 / Math.PI
@@ -51,11 +53,17 @@ export function unwrapLngs(points: [number, number][]): [number, number][] {
* copies), a ±360-shifted duplicate keeps both halves visible in the
* standard [-180, 180] view; GL maps repeat features themselves
* (renderWorldCopies), so the duplicate would just double the line opacity.
*
* Given a longitude frame (Leaflet, a trip across the antimeridian, #2975), the
* arc starts at `a` as the frame draws it, and the duplicate is only needed
* when the arc leaves the frame rather than the plain world.
*/
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean): [number, number][][] {
const arc = unwrapLngs(greatCircle(a, b))
const crosses = arc.some(([, lng]) => lng < -180 || lng > 180)
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean, frame: LngFrame | null = null): [number, number][][] {
const arc = frame ? frameLine(frame, greatCircle(a, b)) : unwrapLngs(greatCircle(a, b))
const west = frame ? frame.seam : -180
const east = west + 360
const crosses = arc.some(([, lng]) => lng < west || lng > east)
if (!crosses || !wrapCopies) return [arc]
const shift = arc.some(([, lng]) => lng < -180) ? 360 : -360
const shift = arc.some(([, lng]) => lng < west) ? 360 : -360
return [arc, arc.map(([lat, lng]) => [lat, lng + shift] as [number, number])]
}
+7 -1
View File
@@ -1,3 +1,4 @@
import { bboxOnRealWorld } from '../../utils/worldCopy';
import { rasterTileZoom } from './glRasterLayers';
import type { MapViewProps } from './mapViewContract';
@@ -16,6 +17,11 @@ interface ViewportSource {
export function reportGlViewport(map: ViewportSource, report: MapViewProps['onViewportChange']): void {
const bounds = map.getBounds();
if (!report || !bounds) return;
const bbox = { south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() };
const bbox = bboxOnRealWorld({
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
});
report(bbox, rasterTileZoom(map.getZoom()));
}
@@ -0,0 +1,120 @@
// The Leaflet map's bridges to the page: the events it hands out, with their
// points on the real world (#2976), and the view kept on the world copy the
// trip is drawn on (#2975).
import { wrapLongitude } from '@trek/shared';
import L from 'leaflet';
import { useEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng, type LngFrame } from '../../utils/lngFrame';
import { bboxOnRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
import type { MapViewProps } from './mapViewContract';
import { bindPoiDropTarget } from './poiDropTarget';
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
export function PoiDropTarget({
onPoiDropOnRoute,
}: {
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void;
}) {
const map = useMap();
useEffect(() => {
if (!onPoiDropOnRoute) return;
return bindPoiDropTarget(map.getContainer(), (x, y) => map.containerPointToLatLng([x, y]), onPoiDropOnRoute);
}, [map, onPoiDropOnRoute]);
return null;
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
export function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
const map = useMap();
useEffect(() => {
if (!onViewportChange) return;
const emit = () => {
const b = map.getBounds();
// A view of a world copy (or of a trip drawn past the antimeridian) asks for the real places there.
onViewportChange(
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }),
map.getZoom()
);
};
map.whenReady(emit); // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit);
map.on('zoomend', emit);
return () => {
map.off('moveend', emit);
map.off('zoomend', emit);
};
}, [map, onViewportChange]);
return null;
}
/**
* Moves the view along when the longitude frame changes under it (#2975): the trip
* is then drawn one world further east or west, and a view left where it was would
* look at an empty copy. One jump of a whole world, so nothing on screen changes.
*/
export function LngFrameFollower() {
const map = useMap();
const frame = useLngFrame();
const prev = useRef<LngFrame | null>(frame);
useEffect(() => {
const before = prev.current;
prev.current = frame;
if (before === frame || typeof map.getCenter !== 'function') return;
const center = map.getCenter();
// Without a frame the trip is back on the real world, and so is the view.
const target = frame ? frameLng(frame, center.lng) : wrapLongitude(center.lng);
const shift = target - center.lng;
if (shift !== 0) map.setView([center.lat, target], map.getZoom(), { animate: false });
}, [frame, map]);
return null;
}
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null;
}
/**
* The event as the map reports it, with its point moved from a repeated world copy
* back onto the real world: a right-click on the copy east of it said 274.9 for
* -85.1, and the place was saved there (#2976).
*/
function onRealWorldEvent(e: L.LeafletMouseEvent): L.LeafletMouseEvent {
const lng = wrapLongitude(e.latlng.lng);
return lng === e.latlng.lng ? e : { ...e, latlng: L.latLng(e.latlng.lat, lng) };
}
export function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap();
useEffect(() => {
if (!onClick) return;
const handle = (e: L.LeafletMouseEvent) => onClick(onRealWorldEvent(e));
map.on('click', handle);
return () => {
map.off('click', handle);
};
}, [map, onClick]);
return null;
}
export function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap();
useEffect(() => {
if (!onContextMenu) return;
const handle = (e: L.LeafletMouseEvent) => onContextMenu(onRealWorldEvent(e));
map.on('contextmenu', handle);
return () => {
map.off('contextmenu', handle);
};
}, [map, onContextMenu]);
return null;
}
@@ -0,0 +1,51 @@
// The longitude frame of the Leaflet map, for every layer that draws in it (#2975).
// See utils/lngFrame.ts for what the frame is and why only Leaflet needs it.
import { createContext, useContext, useMemo, useState } from 'react';
import type { Place, Reservation } from '../../types';
import { tripLngFrame, type LngFrame } from '../../utils/lngFrame';
const LngFrameContext = createContext<LngFrame | null>(null);
export const LngFrameProvider = LngFrameContext.Provider;
/** The frame the map draws in, or null when it draws the plain world. */
export function useLngFrame(): LngFrame | null {
return useContext(LngFrameContext);
}
type Located = Pick<Place, 'lng'>;
function tripLngs(
places: readonly Located[],
dayPlaces: readonly Located[],
reservations: readonly Reservation[]
): number[] {
const lngs: number[] = [];
for (const p of places) if (typeof p.lng === 'number') lngs.push(p.lng);
for (const p of dayPlaces) if (typeof p.lng === 'number') lngs.push(p.lng);
for (const r of reservations) for (const e of r.endpoints ?? []) if (typeof e.lng === 'number') lngs.push(e.lng);
return lngs;
}
/**
* The frame for a trip: its places, the day's places and every endpoint of every
* booking, shown or not, so switching a day or a booking on and off does not move
* the seam. The frame in use is handed back in, which keeps it while it still fits.
*/
export function useTripLngFrame(
places: readonly Located[],
dayPlaces: readonly Located[],
reservations: readonly Reservation[]
): LngFrame | null {
const [current, setCurrent] = useState<LngFrame | null>(() =>
tripLngFrame(tripLngs(places, dayPlaces, reservations))
);
const next = useMemo(
() => tripLngFrame(tripLngs(places, dayPlaces, reservations), current),
[places, dayPlaces, reservations, current]
);
// Derived state, adjusted during render: the next render already draws in the new frame.
if (next !== current) setCurrent(next);
return next;
}
@@ -0,0 +1,39 @@
// A corridor hit dropped anywhere on the map, for every renderer.
//
// The map takes the drop, not the drawn route: in a GL renderer the line lives in the
// canvas and has no element to aim at, and the coordinate under the pointer says just
// as much once the caller projects it onto the routed geometry. The point goes out on
// the real world, whichever world copy it was dropped on (#2976).
import { onRealWorld } from '../../utils/worldCopy';
import { draggedPoiId } from './markerDrag';
/** Where a point of the container lies, in the renderer's own coordinates. */
type PointAt = (x: number, y: number) => { lat: number; lng: number };
/** Listens for POI drops on `container` and reports each one; returns the cleanup. */
export function bindPoiDropTarget(
container: HTMLElement,
pointAt: PointAt,
onPoiDropOnRoute: (osmId: string, lat: number, lng: number) => void
): () => void {
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
};
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e);
if (!osmId) return;
e.preventDefault();
const rect = container.getBoundingClientRect();
const at = onRealWorld(pointAt(e.clientX - rect.left, e.clientY - rect.top));
onPoiDropOnRoute(osmId, at.lat, at.lng);
};
container.addEventListener('dragover', onDragOver);
container.addEventListener('drop', onDrop);
return () => {
container.removeEventListener('dragover', onDragOver);
container.removeEventListener('drop', onDrop);
};
}
@@ -88,6 +88,20 @@ describe('transportHop', () => {
expect(transportHop(r, false)!.arcs[0].length).toBeGreaterThan(2);
});
it("draws the waypoints and arcs in the map's longitude frame, so the pins sit on the line (#2975)", () => {
const r = booking('flight', [
endpoint({ role: 'from', lat: 33.94, lng: -118.41 }),
endpoint({ role: 'to', sequence: 1, lat: -37.01, lng: 174.79 }),
]);
const hop = transportHop(r, true, { seam: 28 });
if (!hop) throw new Error('a flight with two located ends draws a hop');
expect(hop.from.lng).toBeCloseTo(241.59, 5);
expect(hop.to.lng).toBeCloseTo(174.79, 5);
expect(hop.arcs).toHaveLength(1);
expect(hop.arcs[0][0][1]).toBeCloseTo(241.59, 5);
expect(hop.arcs[0][hop.arcs[0].length - 1][1]).toBeCloseTo(174.79, 5);
});
it('draws ferries and cruises as arcs but a cable car as a straight line', () => {
expect(TRANSPORT_META.ferry.geodesic).toBe(true);
expect(TRANSPORT_META.cruise.geodesic).toBe(true);
+9 -5
View File
@@ -7,6 +7,7 @@
import { Bike, Bus, CableCar, Car, CarTaxiFront, Plane, Route, Sailboat, Ship, Train, TramFront } from 'lucide-react';
import type { Reservation, ReservationEndpoint } from '../../types';
import { haversineKm } from '../../utils/geo';
import { frameLine, frameLng, type LngFrame } from '../../utils/lngFrame';
import { geodesicArcs } from './flightGeodesy';
export type TransportType =
@@ -144,14 +145,17 @@ export interface TransportHop {
* antimeridian also gets a copy shifted by 360 degrees: Leaflet needs that, while a
* GL map repeats features across world copies itself, so there one continuous arc
* is enough (a shifted duplicate would sit on the wrapped copy and double the opacity).
* `frame` is Leaflet's longitude frame (#2975): the arcs and the waypoints come back
* drawn in it, so a pin and the line that ends at it are on the same world copy.
*/
export function transportHop(r: Reservation, wrapArcs: boolean): TransportHop | null {
export function transportHop(r: Reservation, wrapArcs: boolean, frame: LngFrame | null = null): TransportHop | null {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) return null;
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two.
const waypoints = (r.endpoints || [])
const sorted = (r.endpoints || [])
.filter((e) => e.role === 'from' || e.role === 'to' || e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
const waypoints = frame ? sorted.map((e) => ({ ...e, lng: frameLng(frame, e.lng) })) : sorted;
if (waypoints.length < 2) return null;
const from = waypoints[0];
const to = waypoints[waypoints.length - 1];
@@ -164,12 +168,12 @@ export function transportHop(r: Reservation, wrapArcs: boolean): TransportHop |
const a = waypoints[i];
const b = waypoints[i + 1];
const segArcs = isGeo
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], wrapArcs)
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], wrapArcs, frame)
: [
[
frameLine(frame, [
[a.lat, a.lng],
[b.lat, b.lng],
] as [number, number][],
] as [number, number][]),
];
arcs.push(...segArcs);
distanceKm += haversineKm(a, b);
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { onRealWorld } from '../../utils/worldCopy';
import { mapHoverLabel } from './mapHoverLabel';
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers';
@@ -245,12 +246,15 @@ export function useAssistantGhostLinesGL({ mapRef, mapReady, lines, onHover, onP
return;
}
handlers.current.onHover?.(ref);
handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
const at = onRealWorld(event.lngLat);
handlers.current.onPoint?.(ref, at.lat, at.lng);
};
const leave = () => handlers.current.onHover?.(null);
const click = (event: LayerEvent) => {
const ref = refOf(event);
if (ref !== null && !onPlanPin(map, event)) handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
if (ref === null || onPlanPin(map, event)) return;
const at = onRealWorld(event.lngLat);
handlers.current.onPoint?.(ref, at.lat, at.lng);
};
const stop = (event: LayerEvent) => {
const name = event.features?.[0]?.properties?.label;
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { onRealWorld } from '../../utils/worldCopy';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract';
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
@@ -102,7 +103,7 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect();
const at = map.unproject([clientX - rect.left, clientY - rect.top]);
return { lat: at.lat, lng: at.lng };
return onRealWorld({ lat: at.lat, lng: at.lng });
},
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
onCommit: ({ lat, lng }) =>
@@ -419,8 +419,8 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
// The bringer's initial on an avatar, the co-bringers as a count.
expect(screen.getByTitle('brought by Bob')).toHaveTextContent('B+1')
// The bringer's initial on an avatar, the co-bringers as a count; the title names everyone (#2782).
expect(screen.getByTitle('brought by Bob, cleo')).toHaveTextContent('B+1')
})
it('FE-W5ROW-022: a bringer without co-bringers shows no counter', () => {
@@ -18,6 +18,7 @@ import { PopoverItem } from './PackingPopover'
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
import type { TripMember } from './usePackingListPanel'
import { NumericInput } from '../shared/NumericInput'
import { packingBringerNames } from './packingBringers'
interface ArtikelZeileProps {
item: PackingItem
@@ -226,7 +227,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
</span>
)}
{!isPlaceholder && broughtBy && (
<span className="packing-row-badge" title={t('packing.broughtBy', { name: broughtBy })} aria-label={t('packing.broughtBy', { name: broughtBy })}
<span className="packing-row-badge" title={t('packing.broughtBy', { name: packingBringerNames(item) })} aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
{ownerAvatar
? <img src={ownerAvatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover', display: 'block' }} />
@@ -0,0 +1,22 @@
// FE-PACK-BRINGERS-001 to FE-PACK-BRINGERS-003
import { describe, expect, it } from 'vitest';
import { packingBringerNames } from './packingBringers';
describe('packingBringerNames', () => {
it('FE-PACK-BRINGERS-001: names the bringer first, then everyone who also brings it', () => {
const item = {
owner_username: 'anna',
contributors: [{ username: 'ben' }, { username: 'carla' }],
};
expect(packingBringerNames(item)).toBe('anna, ben, carla');
});
it('FE-PACK-BRINGERS-002: names a person once, even when listed twice', () => {
expect(packingBringerNames({ owner_username: 'anna', contributors: [{ username: 'anna' }] })).toBe('anna');
});
it('FE-PACK-BRINGERS-003: copes with an item without a bringer or contributors', () => {
expect(packingBringerNames({ owner_username: null, contributors: [{ username: 'ben' }] })).toBe('ben');
expect(packingBringerNames({})).toBe('');
});
});
@@ -0,0 +1,14 @@
interface BroughtItem {
owner_username?: string | null;
contributors?: { username: string }[] | null;
}
/**
* Everyone bringing a common item, as one readable list: its bringer first, then
* whoever also brings it. The row only has room for one avatar and a "+n", so this
* is what its tooltip and the item sheet name (#2782).
*/
export function packingBringerNames(item: BroughtItem): string {
const names = [item.owner_username, ...(item.contributors ?? []).map((c) => c.username)];
return [...new Set(names.filter((n): n is string => !!n))].join(', ');
}
@@ -133,7 +133,7 @@ describe('ReservationModal', () => {
expect(screen.getByRole('button', { name: /^Accommodation$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Restaurant$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Event$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Tour$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Guided tour$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Parking$/i })).toBeInTheDocument();
// The pill reads "Booking Type: Other", so only its option is named plain "Other".
expect(screen.getAllByRole('button', { name: /^Other$/i })).toHaveLength(1);
@@ -708,7 +708,7 @@ describe('ReservationModal', () => {
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
render(<ReservationModal {...defaultProps} />);
await pickType(/^Tour$/i);
await pickType(/^Guided tour$/i);
await waitFor(() => {
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
});
@@ -121,7 +121,7 @@ describe('BookingsTimeline: trip view', () => {
expect(screen.getByText('No date')).toBeInTheDocument();
const metro = screen.getByRole('button', { name: /Metro ride/ });
expect(metro).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Tour, Pending');
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Guided tour, Pending');
expect(screen.getByRole('button', { name: /Farewell dinner/ })).toHaveTextContent('Restaurant, Confirmed');
await userEvent.click(screen.getByRole('button', { name: /Farewell dinner/ }));
expect(onSelect).toHaveBeenCalledWith(early);
@@ -2,6 +2,8 @@ import L from 'leaflet';
import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../../i18n';
import { frameLine, frameLng } from '../../../utils/lngFrame';
import { useLngFrame } from '../../Map/lngFrameContext';
import {
isPaddleItemSelected,
markerSize,
@@ -74,6 +76,8 @@ function useLabel() {
export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
const label = useLabel();
const map = useMap();
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame();
const linePane = useLinePane();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
@@ -105,7 +109,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
item.line ? (
<Polyline
key={`${item.feature.ref}-casing`}
positions={item.line}
positions={frameLine(frame, item.line)}
pane={linePane}
pathOptions={{
color: item.disc,
@@ -120,7 +124,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
item.line ? (
<Polyline
key={`${item.feature.ref}-line`}
positions={item.line}
positions={frameLine(frame, item.line)}
pane={linePane}
bubblingMouseEvents={false}
pathOptions={{
@@ -137,7 +141,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
{items.map((item) => (
<Marker
key={`${item.feature.ref}-pin`}
position={item.marker}
position={frame ? [item.marker[0], frameLng(frame, item.marker[1])] : item.marker}
icon={icons.get(item.feature.ref) as L.DivIcon}
zIndexOffset={
isPaddleItemSelected(item, selectedRef, selectedKey) ? 900 : item.relation === 'on_route' ? 600 : 300
@@ -6,6 +6,7 @@ import { avatarSrc } from '../../../../utils/avatarSrc'
import type { PackingItem, TripMember } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
import { isPackingPlaceholder } from '../../../../components/Packing/packingListModel'
import { packingBringerNames } from '../../../../components/Packing/packingBringers'
export interface MPackItemSheetProps {
planner: TripPlanner
@@ -160,7 +161,7 @@ export default function MPackItemSheet({
<div className="rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
{item.owner_username && (
<div className="mb-[9px] font-geist text-[0.6875rem] text-m-muted">
{t('packing.broughtBy', { name: item.owner_username })}
{t('packing.broughtBy', { name: packingBringerNames(item) })}
</div>
)}
<div className="flex gap-2">
@@ -28,6 +28,7 @@ import MPackItemSheet from './MPackItemSheet'
import MPackingImportSheet from './MPackingImportSheet'
import PackingPrintPreview from '../../../../components/Packing/PackingPrintPreview'
import { usePackingExport } from '../../../../components/Packing/usePackingExport'
import { packingBringerNames } from '../../../../components/Packing/packingBringers'
type ActionView = 'menu' | 'apply' | 'save'
@@ -718,7 +719,7 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
</span>
)}
{!badgeSharedToMe && !badgeSharedByMe && badgeBroughtBy && (
<span className="flex flex-none items-center gap-[3px]" title={item.owner_username || undefined}>
<span className="flex flex-none items-center gap-[3px]" title={t('packing.broughtBy', { name: packingBringerNames(item) })} aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}>
{ownerAvatarUrl
? <img src={ownerAvatarUrl} alt={item.owner_username || ''} className="h-5 w-5 flex-none rounded-full object-cover" />
: <span className="flex h-5 w-5 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] font-geist text-[0.5625rem] font-bold text-m-muted">{(item.owner_username || '?')[0]?.toUpperCase()}</span>}
+2
View File
@@ -133,6 +133,7 @@ export default function JourneyPublicPage() {
sortedDates,
displayDates,
newestFirst,
openAtEntryId,
setNewestFirst,
sidebarMapItems,
allPhotos,
@@ -695,6 +696,7 @@ export default function JourneyPublicPage() {
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
<MobileMapTimeline
entries={timelineEntries}
initialEntryId={openAtEntryId}
mapEntries={sidebarMapItems as any}
dark={document.documentElement.classList.contains('dark')}
readOnly
@@ -10,6 +10,8 @@ import type { JourneyMapAutoHandle as JourneyMapHandle } from '../../components/
import { useToast } from '../../components/shared/Toast'
import { useIsMobile } from '../../hooks/useIsMobile'
import { lockBodyScroll } from '../../utils/bodyScrollLock'
import { localIsoDate } from '../../utils/localDate'
import { journeyOpenAtEntryId } from '../../components/Journey/journeyOpenAt'
import type { JourneyEntry } from '../../store/journeyStore'
import { createDraftJourneyEntry, photoUrl } from './JourneyDetailPage.helpers'
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
@@ -475,34 +477,11 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
})
}, [current?.gallery])
/**
* Where a journey should open: on today, when today is part of it.
*
* Every load used to start at the first entry, so keeping a journal on a trip
* meant scrolling past everything already written before you could add to it
* (discussion #2299). "Today" means the last day at or before now — a rest day
* with nothing written still lands you at yesterday rather than at the start.
*
* Only while the journey is running, though. A journey that ended last spring
* is something you read, and reading starts at the beginning; opening it at the
* last page would be the worse of the two mistakes.
*/
/** Where a journey should open: a linked entry, else today while it runs (see journeyOpenAtEntryId). */
const openAtEntryId = useMemo(() => {
const entries = current?.entries ?? []
if (entries.length === 0) return null
if (linkedEntryId !== null && entries.some(e => String(e.id) === linkedEntryId)) return linkedEntryId
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
const today = new Date()
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
const first = dated[0]!.entry_date
const last = dated[dated.length - 1]!.entry_date
if (todayKey < first || todayKey > last) return null
const reached = dated.filter(e => e.entry_date <= todayKey)
const target = reached[reached.length - 1] ?? dated[0]!
// The first entry OF that day, not the last one before now: a day is read
// from its beginning.
const dayStart = dated.find(e => e.entry_date === target.entry_date) ?? target
return String(dayStart.id)
return journeyOpenAtEntryId(entries, { today: localIsoDate() })
}, [current?.entries, linkedEntryId])
// Desktop only: scroll it into the feed once per journey and once per link, after
@@ -1,4 +1,4 @@
// FE-JRN-PUBHOOK-001 to FE-JRN-PUBHOOK-002
// FE-JRN-PUBHOOK-001 to FE-JRN-PUBHOOK-003
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
@@ -32,14 +32,14 @@ function buildEntry(over: Partial<PublicEntry> & { id: number }): PublicEntry {
};
}
function serveShare(entries: PublicEntry[]): void {
function serveShare(entries: PublicEntry[], permissions: Record<string, boolean> = {}): void {
server.use(
http.get('/api/public/journey/tok-1', () =>
HttpResponse.json({
journey: { title: 'Japan' },
entries,
gallery: [],
permissions: { share_timeline: true, share_gallery: true, share_map: false },
permissions: { share_timeline: true, share_gallery: true, share_map: false, ...permissions },
stats: {},
})
)
@@ -91,4 +91,21 @@ describe('useJourneyPublic', () => {
]);
expect(result.current.stopNumberById.has('7')).toBe(false);
});
it('FE-JRN-PUBHOOK-003: a link published newest-first opens on the first entry of the latest day', async () => {
serveShare(
[
buildEntry({ id: 1, entry_date: '2020-03-01' }),
buildEntry({ id: 2, entry_date: '2020-03-02' }),
buildEntry({ id: 3, entry_date: '2020-03-02', sort_order: 1 }),
],
{ newest_first: true }
);
const { result } = renderHook(() => useJourneyPublic());
await waitFor(() => expect(result.current.openAtEntryId).toBe('2'));
result.current.setNewestFirst(false);
// A journey that ended long ago, read oldest-first, starts at the beginning.
await waitFor(() => expect(result.current.openAtEntryId).toBeNull());
});
});
@@ -9,6 +9,8 @@ import { journeyMapStops } from '../../components/Journey/journeyMapStops'
import { posterlessVideo } from '../journeyDetail/JourneyDetailPage.helpers'
import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublicModel'
import { useJourneyMapLayers } from '../../components/Journey/useJourneyMapLayers'
import { journeyOpenAtEntryId } from '../../components/Journey/journeyOpenAt'
import { localIsoDate } from '../../utils/localDate'
/**
* Public-journey (read-only share) data hook — owns the token fetch, the
@@ -144,6 +146,12 @@ export function useJourneyPublic() {
() => (effectiveNewestFirst ? [...sortedDates].reverse() : sortedDates),
[sortedDates, effectiveNewestFirst],
)
// The phone's map carousel keeps the trip in time order, so newest-first there means
// opening on the latest day instead of reversing the cards (#2851).
const openAtEntryId = useMemo(
() => journeyOpenAtEntryId(timelineEntries, { today: localIsoDate(), newestFirst: effectiveNewestFirst }),
[timelineEntries, effectiveNewestFirst],
)
// Two-column desktop layout: timeline feed left + sticky map right
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
@@ -166,7 +174,7 @@ export function useJourneyPublic() {
perms, journey, stats,
timelineEntries, groupedEntries, sortedDates, displayDates, sidebarMapItems, allPhotos, stopNumberById, mapPhotos,
mapLayers, entryTitleOf,
newestFirst: effectiveNewestFirst, setNewestFirst,
newestFirst: effectiveNewestFirst, setNewestFirst, openAtEntryId,
desktopTwoColumn,
}
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { frameLine, frameLng, tripLngFrame } from './lngFrame';
const LA = -118.4;
const AUCKLAND = 174.8;
describe('tripLngFrame (#2975)', () => {
it('gives a trip that stays off the antimeridian no frame, so every layer draws as before', () => {
expect(tripLngFrame([2.35, 13.4, -3.7, 12.5])).toBeNull();
expect(tripLngFrame([LA, -73.9, -0.1, 139.7])).toBeNull();
expect(tripLngFrame([])).toBeNull();
expect(tripLngFrame([LA])).toBeNull();
});
it('seams a trip across the Pacific in the middle of the stretch it leaves empty', () => {
const frame = tripLngFrame([LA, -118.3, AUCKLAND, 174.76]);
expect(frame).not.toBeNull();
expect(frame?.seam).toBeCloseTo((-118.3 + 174.76) / 2, 5);
expect(frameLng(frame, LA)).toBeCloseTo(LA + 360, 9);
expect(frameLng(frame, AUCKLAND)).toBeCloseTo(AUCKLAND, 9);
});
it('seams Fiji to Samoa, a few degrees apart across the date line', () => {
const frame = tripLngFrame([178.4, -171.8]);
expect(frame).not.toBeNull();
expect(frameLng(frame, -171.8) - frameLng(frame, 178.4)).toBeCloseTo(9.8, 9);
});
it('leaves a trip around the world alone when re-seaming would barely narrow it', () => {
expect(tripLngFrame([-150, -90, -30, 30, 90, 150])).toBeNull();
});
it('keeps the frame in use while it still fits, so a new place does not slide the map', () => {
const first = tripLngFrame([LA, AUCKLAND]);
expect(tripLngFrame([LA, AUCKLAND, 151.2], first)).toBe(first);
});
it('reads a longitude from a world copy where it really is', () => {
const fromCopies = tripLngFrame([LA + 360, AUCKLAND - 720]);
expect(fromCopies?.seam).toBeCloseTo(tripLngFrame([LA, AUCKLAND])?.seam ?? Number.NaN, 9);
});
});
describe('frameLng', () => {
it('draws a longitude where it is without a frame', () => {
expect(frameLng(null, LA)).toBe(LA);
expect(frameLng(null, 400)).toBe(400);
});
it('moves any longitude into the frame, and one already in it nowhere', () => {
const frame = { seam: 28 };
expect(frameLng(frame, LA)).toBeCloseTo(241.6, 9);
expect(frameLng(frame, 241.6)).toBeCloseTo(241.6, 9);
expect(frameLng(frame, 28)).toBe(28);
expect(frameLng(frame, 27.9)).toBeCloseTo(387.9, 9);
});
});
describe('frameLine', () => {
it('hands the line back untouched without a frame', () => {
const line: [number, number][] = [
[0, 170],
[0, -170],
];
expect(frameLine(null, line)).toBe(line);
});
it('starts the line in the frame and keeps it one line across the antimeridian', () => {
const drawn = frameLine({ seam: 28 }, [
[34, LA],
[0, -160],
[-20, 179],
[-37, AUCKLAND],
]);
expect(drawn.map(([, lng]) => Math.round(lng * 10) / 10)).toEqual([241.6, 200, 179, 174.8]);
});
it('runs past the edge of the frame rather than jumping back across the world', () => {
const drawn = frameLine({ seam: 28 }, [
[0, 30],
[0, 20],
]);
expect(drawn.map(([, lng]) => lng)).toEqual([30, 20]);
});
});
+108
View File
@@ -0,0 +1,108 @@
// One stretch of longitudes to draw a trip in, for a map that does not repeat its
// layers across world copies (Leaflet) (#2975).
//
// Leaflet draws a marker at its literal longitude only, on one copy of the world.
// A trip from Los Angeles (-118) to Auckland (174.8) therefore had its pins at
// opposite ends of that copy, while the flight between them, drawn the short way
// across the Pacific, ran off the edge to the Los Angeles of the next copy. The
// frame moves the seam of the drawn world into the widest stretch of the planet
// the trip leaves empty, so the whole trip, flight included, lies on one copy:
// Los Angeles at 242, Auckland at 174.8.
//
// A trip that does not cross the antimeridian gets no frame at all, and every
// layer draws exactly what it drew before.
import { wrapLongitude } from '@trek/shared';
/** The drawn world runs from `seam` to `seam + 360`. */
export interface LngFrame {
readonly seam: number;
}
/** Re-seam only when that narrows the trip by at least this much: a world tour stays as it is. */
const MIN_SAVING_DEG = 45;
/** A previous seam is kept while the trip drawn with it is at most this much wider than the best. */
const KEEP_SEAM_SLACK_DEG = 15;
/** Width of the trip, in degrees, when the world is drawn from `seam` eastwards. */
function spanFrom(seam: number, sorted: number[]): number {
let min = Infinity;
let max = -Infinity;
for (const lng of sorted) {
const framed = lng < seam ? lng + 360 : lng;
if (framed < min) min = framed;
if (framed > max) max = framed;
}
return max - min;
}
/**
* The frame for these longitudes, or null when the plain world already holds the
* trip narrowly enough. `previous` is the frame in use: it is kept while it still
* works, so adding or filtering a place does not slide every layer sideways.
*/
export function tripLngFrame(lngs: readonly number[], previous: LngFrame | null = null): LngFrame | null {
const sorted = lngs
.filter(Number.isFinite)
.map(wrapLongitude)
.sort((a, b) => a - b);
if (sorted.length < 2) return null;
const last = sorted.length - 1;
// The widest stretch with no point in it. The one across the antimeridian comes first,
// so a trip that leaves the Pacific empty keeps the plain world.
let gapSize = sorted[0] + 360 - sorted[last];
let gapWest = sorted[last];
let crossesAntimeridian = false;
for (let i = 0; i < last; i++) {
const size = sorted[i + 1] - sorted[i];
if (size > gapSize) {
gapSize = size;
gapWest = sorted[i];
crossesAntimeridian = true;
}
}
if (!crossesAntimeridian) return null;
const plainSpan = sorted[last] - sorted[0];
const bestSpan = 360 - gapSize;
if (plainSpan - bestSpan < MIN_SAVING_DEG) return null;
if (previous && spanFrom(previous.seam, sorted) <= bestSpan + KEEP_SEAM_SLACK_DEG) return previous;
// The middle of the empty stretch: lines that bulge a little past a stop stay clear of it.
return { seam: gapWest + gapSize / 2 };
}
/** Where a longitude is drawn in the frame. Without a frame it is drawn where it is. */
export function frameLng(frame: LngFrame | null, lng: number): number {
if (!frame) return lng;
return frame.seam + ((((lng - frame.seam) % 360) + 360) % 360);
}
/**
* A line in the frame: its first point moved into it, every next point kept within
* half a world of the one before, so the line stays one line where it crosses the
* antimeridian or the seam. Without a frame the line is drawn as it is.
*/
export function frameLine<T extends readonly [number, number]>(
frame: LngFrame | null,
line: readonly T[]
): [number, number][] {
if (!frame) return line as unknown as [number, number][];
const out: [number, number][] = [];
let prevRaw = 0;
let prevDrawn = 0;
for (let i = 0; i < line.length; i++) {
const [lat, lng] = line[i];
if (i === 0) {
prevDrawn = frameLng(frame, lng);
} else {
let delta = lng - prevRaw;
delta -= 360 * Math.round(delta / 360);
prevDrawn += delta;
}
prevRaw = lng;
out.push([lat, prevDrawn]);
}
return out;
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { bboxOnRealWorld, onRealWorld } from './worldCopy';
describe('onRealWorld', () => {
it('brings a right-click on the copy east of the real world back to the real point (#2976)', () => {
const at = onRealWorld({ lat: 10.14, lng: 274.921875 });
expect(at.lat).toBe(10.14);
expect(at.lng).toBeCloseTo(-85.078125, 9);
});
it('brings a point on the copy west of it back too', () => {
expect(onRealWorld({ lat: 35.68, lng: -220.3 }).lng).toBeCloseTo(139.7, 9);
});
it('hands a point already on the real world back as it is', () => {
const point = { lat: 34, lng: -118.4, extra: 1 };
expect(onRealWorld(point)).toBe(point);
});
it('keeps any other field of the point', () => {
expect(onRealWorld({ lat: 1, lng: 365, name: 'x' })).toEqual({ lat: 1, lng: 5, name: 'x' });
});
});
describe('bboxOnRealWorld', () => {
it('moves a box looking at a copy of Japan onto Japan', () => {
const box = bboxOnRealWorld({ south: 34, west: -225, north: 37, east: -215 });
expect(box.west).toBeCloseTo(135, 9);
expect(box.east).toBeCloseTo(145, 9);
expect([box.south, box.north]).toEqual([34, 37]);
});
it('leaves a box on the real world alone', () => {
const box = { south: 40, west: 0, north: 50, east: 20 };
expect(bboxOnRealWorld(box)).toBe(box);
});
it('keeps a box across the antimeridian across it', () => {
expect(bboxOnRealWorld({ south: -20, west: 170, north: -10, east: 190 })).toEqual({
south: -20,
west: 170,
north: -10,
east: 190,
});
});
});
+32
View File
@@ -0,0 +1,32 @@
// Coordinates the map hands out, brought back from a repeated world copy (#2976).
//
// Leaflet and MapLibre/Mapbox repeat the world sideways, and a pointer on a copy
// reports the longitude it has on that copy: 274.9 for -85.1. Anything that
// stores a point or asks a server about it wants the point on the real world.
import { wrapLongitude } from '@trek/shared';
/** The same point with its longitude on the real world, in [-180, 180]. */
export function onRealWorld<T extends { lat: number; lng: number }>(point: T): T {
const lng = wrapLongitude(point.lng);
return lng === point.lng ? point : { ...point, lng };
}
export interface Bbox {
south: number;
west: number;
north: number;
east: number;
}
/**
* A viewport box moved whole by a multiple of 360 degrees so its centre lies on
* the real world. A box looking at a copy of Japan then asks for Japan. A box
* that spans the antimeridian keeps one edge past it, as it always had.
*/
export function bboxOnRealWorld<T extends Bbox>(bbox: T): T {
const centre = (bbox.west + bbox.east) / 2;
const shift = wrapLongitude(centre) - centre;
if (shift === 0) return bbox;
return { ...bbox, west: bbox.west + shift, east: bbox.east + shift };
}
+2 -2
View File
@@ -1,13 +1,13 @@
{
"scripts/db-call-graph.mjs": 1036,
"scripts/generate-entities.ts": 2259,
"src/db/repositories/Places.repository.ts": 1438,
"src/db/repositories/Places.repository.ts": 1437,
"src/db/repositories/Reservations.repository.ts": 1604,
"src/db/repositories/Trips.repository.ts": 1121,
"src/db/repositories/Users.repository.ts": 1445,
"src/nest/atlas/atlas.service.ts": 1177,
"src/nest/auth/auth.service.ts": 1143,
"src/nest/budget/budget.service.ts": 1613,
"src/nest/budget/budget.service.ts": 1612,
"src/nest/collections/collections.service.ts": 1426,
"src/nest/doc-sync/doc-sync.service.ts": 1060,
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
@@ -0,0 +1,34 @@
import { Migration } from '@mikro-orm/migrations';
/**
* The tables whose `lng` a click on a repeated world copy could have written, and
* the outbox that would still hand such a place on to the shared places index.
*/
const TABLES = ['places', 'reservation_endpoints', 'collection_places', 'place_contrib_outbox'] as const;
/**
* How many whole worlds `lng` lies east of the real one: floor((lng + 180) / 360),
* spelled with CAST, which truncates towards zero, because SQLite has no floor()
* without its math extension.
*/
const WORLDS = '(CAST((lng + 180) / 360.0 AS INTEGER) - ((lng + 180) / 360.0 < CAST((lng + 180) / 360.0 AS INTEGER)))';
/**
* Brings every stored longitude back onto the real world.
*
* The map repeats the world sideways, and a right-click or a drag on one of the
* copies reported its longitude as it is on that copy: 274.9 instead of -85.1.
* Saved like that, a place sat on its copy for good, out of reach of the
* geocoder, and a day route to it ran across the whole map (#2976). New writes
* are wrapped on the way in; this moves the ones already stored to the same
* point on the real world. Only rows outside [-180, 180] are touched.
*/
export class Migration20200101046350_a_place_lies_on_the_real_world extends Migration {
override name = 'Migration20200101046350_a_place_lies_on_the_real_world';
override up(): void {
for (const table of TABLES) {
this.addSql(`UPDATE ${table} SET lng = lng - 360 * ${WORLDS} WHERE lng < -180 OR lng > 180`);
}
}
}
@@ -0,0 +1,22 @@
import { Migration } from '@mikro-orm/migrations';
/**
* A new install starts with Journey and Collections switched on; an existing
* install keeps every addon exactly as its admin left it.
*
* Both rows are written off by older migrations (journey_addon, collections_addon)
* that every install runs, and the addon seeder never overwrites a row that is
* already there, so the seeder alone cannot change their start value. A database
* whose `users` table is still empty here is being set up for the first time:
* migrations run before the seeders, and the admin seeder writes the first
* account only afterwards.
*/
export class Migration20200101046360_a_new_install_starts_with_more_addons_on extends Migration {
override name = 'Migration20200101046360_a_new_install_starts_with_more_addons_on';
override up(): void {
this.addSql(
"UPDATE addons SET enabled = 1 WHERE id IN ('journey', 'collections') AND NOT EXISTS (SELECT 1 FROM users)",
);
}
}
@@ -3,6 +3,7 @@ import type { CollectionPlaces } from '../entities/CollectionPlaces.entity';
import type { DB } from '../kysely/db';
import type { CollectionPlaceRow } from './Collections.repository';
import { TrekRepository } from './_shared/trek-repository';
import { wrapOptionalLongitude } from '@trek/shared';
/**
* `collection_places` (Plan 3h Task 2, part B — saved places CRUD, dedup,
@@ -115,7 +116,7 @@ export class CollectionPlacesRepository extends TrekRepository<CollectionPlaces>
name: row.name,
description: row.description,
lat: row.lat,
lng: row.lng,
lng: wrapOptionalLongitude(row.lng),
address: row.address,
category: row.category_id,
price: row.price,
@@ -181,6 +182,7 @@ export class CollectionPlacesRepository extends TrekRepository<CollectionPlaces>
{ id },
{
...rest,
...(rest.lng !== undefined ? { lng: wrapOptionalLongitude(rest.lng) } : {}),
...(category_id !== undefined ? { category: category_id } : {}),
...(collection_id !== undefined ? { collection: collection_id } : {}),
...(owner_id !== undefined ? { owner: owner_id } : {}),
@@ -1,4 +1,5 @@
import type { Places } from '../entities/Places.entity';
import { wrapOptionalLongitude } from '@trek/shared';
import { absDifference, coalesceParam, columnRef, currentTimestamp, lowerTrim } from '../dialect/sql-functions';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -347,10 +348,8 @@ export class PlacesRepository extends TrekRepository<Places> {
* builds. Every value here is already fully coerced by the caller (the
* `?? null` vs `|| null` split PL4's own legacy comment documents — 0 is
* legitimate for `lat`/`lng`/`price`/`fill_percent`, and
* `duration_minutes` defaults to 60 — stays the service's decision, this
* writes exactly what it is handed, `DaysRepository.createDay`'s split).
* `em.insert()` returns the generated PK (R6's `lastInsertRowid`
* replacement).
* `duration_minutes` defaults to 60 — stays the service's decision), but
* `lng` is stored on the real world (#2976). Returns the generated PK.
*/
async insertPlace(input: {
trip_id: number;
@@ -386,7 +385,7 @@ export class PlacesRepository extends TrekRepository<Places> {
name: input.name,
description: input.description,
lat: input.lat,
lng: input.lng,
lng: wrapOptionalLongitude(input.lng),
address: input.address,
category: input.category_id,
price: input.price,
@@ -466,7 +465,7 @@ export class PlacesRepository extends TrekRepository<Places> {
name: write.name,
description: write.description,
lat: write.lat,
lng: write.lng,
lng: wrapOptionalLongitude(write.lng),
address: write.address,
category: write.category_id,
price: write.price,
@@ -963,7 +962,7 @@ export class PlacesRepository extends TrekRepository<Places> {
name: input.name,
description: input.description,
lat: input.lat,
lng: input.lng,
lng: wrapOptionalLongitude(input.lng),
address: input.address,
category: input.category_id,
price: input.price,
@@ -1338,7 +1337,7 @@ export class PlacesRepository extends TrekRepository<Places> {
trip: input.trip_id,
name: input.name,
lat: input.lat,
lng: input.lng,
lng: wrapOptionalLongitude(input.lng),
transport_mode: 'walking',
route_geometry: input.route_geometry,
description: input.description ?? null,
@@ -1360,7 +1359,7 @@ export class PlacesRepository extends TrekRepository<Places> {
{
name: input.name,
lat: input.lat,
lng: input.lng,
lng: wrapOptionalLongitude(input.lng),
transport_mode: 'walking',
route_geometry: input.route_geometry,
...tourPlaceTextPatch(input),
@@ -4,6 +4,7 @@ import type { DB } from '../kysely/db';
import { travelerOwnsExpr, type ReservationTravelersOwnsKyselyDB } from './_shared/reservation-travelers-owns';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import { wrapLongitude } from '@trek/shared';
/** A `reservation_endpoints` row exactly as `r.*`/`SELECT *` read it (every scalar column). */
export interface ReservationEndpointRow {
@@ -82,7 +83,7 @@ export class ReservationEndpointsRepository extends TrekRepository<ReservationEn
name: input.name,
code: input.code,
lat: input.lat,
lng: input.lng,
lng: wrapLongitude(input.lng),
timezone: input.timezone,
local_time: input.local_time,
local_date: input.local_date,
+8 -2
View File
@@ -110,7 +110,7 @@ export const DEFAULT_ADDONS: AddonRow[] = [
description: 'Trip tracking & travel journal — check-ins, photos, daily stories',
type: 'global',
icon: 'Compass',
enabled: 0,
enabled: 1,
sort_order: 35,
},
{
@@ -157,7 +157,7 @@ export const DEFAULT_ADDONS: AddonRow[] = [
'Personal place library — save places across trips into named lists, copy into any trip, share with others',
type: 'global',
icon: 'Bookmark',
enabled: 0,
enabled: 1,
sort_order: 16,
},
];
@@ -169,6 +169,12 @@ export const DEFAULT_ADDONS: AddonRow[] = [
* table-level emptiness check: an operator who has toggled an addon keeps their
* choice, and a release that adds a new addon still gets its tile on an
* existing install.
*
* `enabled` is the start value on a new install: every trip addon but Road trip,
* plus Journey, Collections, Atlas, Vacay and the Naver import. The Journey and
* Collections rows are written by older migrations before this runs, so a new
* install has them switched on by Migration20200101046360; on an existing install
* nothing here changes.
*/
export class AddonSeeder extends Seeder {
async run(em: EntityManager): Promise<void> {
+4 -4
View File
@@ -26,7 +26,7 @@ import { BudgetWritesService } from './budget-writes.service';
import { BudgetService } from './budget.service';
import { ExchangeRatesService } from './exchange-rates.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { idSchema, sumMinor, toMinor } from '@trek/shared';
import { COST_CATEGORY_HINT, idSchema, sumMinor, toMinor } from '@trek/shared';
import { z } from 'zod';
@@ -200,7 +200,7 @@ export class BudgetMcp {
inputSchema: {
tripId: idSchema,
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'),
currency: z
.string()
@@ -341,7 +341,7 @@ export class BudgetMcp {
tripId: idSchema,
itemId: idSchema,
name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(),
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
total_price: z.number().optional(),
currency: z
.string()
@@ -482,7 +482,7 @@ export class BudgetMcp {
inputSchema: {
tripId: idSchema,
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'),
note: z.string().max(500).optional(),
userIds: z
+7 -8
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { BudgetFallbackFx, BudgetParticipantFinal, BudgetUnconverted, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { splitEqualShares, sumMinor, toMinor } from '@trek/shared';
import { costCategoryOf, splitEqualShares, storedCostCategory, sumMinor, toMinor } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../common/avatarUrl';
@@ -91,8 +91,7 @@ export function splitLegacyTicketNote(
* queries instead of a full listSettlements() scan, settlement usernames use
* COALESCE(display_name, username) like every item query, and updateMembers no
* longer double-applies avatarUrl.
*/
@Injectable()
*/@Injectable()
export class BudgetService {
constructor(
private readonly permissions: PermissionsService,
@@ -520,7 +519,7 @@ export class BudgetService {
const maxOrder = await this.budgetItemsRepo.maxSortOrder(tripId);
const sortOrder = (maxOrder !== null ? maxOrder : -1) + 1;
const cat = data.category || 'other';
const cat = storedCostCategory(data.category);
// Ensure category has a sort_order entry
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, cat);
@@ -660,7 +659,7 @@ export class BudgetService {
// (the legacy `COALESCE(?, col)` truthy-wins shape), everything else
// passes `present = <field> !== undefined` (a true presence sentinel).
await this.budgetItemsRepo.update(id, {
category: [!!data.category, data.category || ''],
category: [!!data.category, data.category ? storedCostCategory(data.category) : ''],
name: [!!data.name, data.name || ''],
total_price: [data.total_price !== undefined, data.total_price !== undefined ? data.total_price : 0],
currency: [data.currency !== undefined, data.currency !== undefined ? (data.currency || null) : null],
@@ -705,11 +704,11 @@ export class BudgetService {
// If category changed, update category order table
if (data.category) {
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, data.category);
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, storedCostCategory(data.category));
if (!catExists) {
const maxCatOrder = await this.budgetCategoryOrderRepo.maxSortOrder(tripId);
const catOrder = (maxCatOrder !== null && maxCatOrder !== undefined ? maxCatOrder : -1) + 1;
await this.budgetCategoryOrderRepo.insertIgnore(tripId, data.category, catOrder);
await this.budgetCategoryOrderRepo.insertIgnore(tripId, storedCostCategory(data.category), catOrder);
}
}
@@ -1037,7 +1036,7 @@ export class BudgetService {
}
const cents = toMinor(convert(r.total_price || 0));
total += cents;
const cat = r.category || '';
const cat = costCategoryOf(r.category) ?? (r.category || '');
byCategory[cat] = (byCategory[cat] || 0) + cents;
}
return {
+1 -1
View File
@@ -383,7 +383,7 @@ export class JourneyMcp {
tags: z.array(z.string()).optional(),
pros_cons: PROS_CONS.optional().describe('The verdict on the place: what was worth it and what was not'),
visibility: ENTRY_VISIBILITY.optional().describe(
'Defaults to private; "shared" and "public" expose the entry through the journey share link',
'Stored with the entry, defaults to private. It does not decide the share link: every entry that is not a draft shows on the journey share link. Use is_draft to keep an entry to the contributors',
),
type: ENTRY_TYPE.optional().describe(
'Defaults to "entry"; "skeleton" is the stub TREK derives from a trip place and hides behind the hide-skeletons preference',
+13 -1
View File
@@ -39,7 +39,7 @@ import {
} from './trek-places.client';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import { isOutsideChina } from '@trek/shared';
import { isOutsideChina, wrapLongitude } from '@trek/shared';
import type { MapsSearchResult, MapsAutocompleteResult, MapsPlaceDetailsResult } from '@trek/shared';
import type { MapsPlacePhotoResult, MapsReverseResult, MapsResolveUrlResult } from '@trek/shared';
@@ -57,6 +57,17 @@ const NEARBY_CACHE = new Map<string, { at: number; value: { places: Record<strin
const NEARBY_CACHE_TTL_MS = 30 * 60 * 1000;
const NEARBY_CACHE_MAX = 500;
/**
* The reverse-geocode longitude, as text, moved back onto the real world when a
* right-click on a repeated world copy sent it past 180 (#2976): the geocoders
* answer nothing out there. A value already in range is passed on exactly as it came.
*/
function onTheRealWorld(lng: string): string {
const value = Number.parseFloat(lng);
if (!Number.isFinite(value) || (value >= -180 && value <= 180)) return lng;
return String(wrapLongitude(value));
}
type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } };
/**
@@ -649,6 +660,7 @@ export class MapsService {
lang?: string,
opts?: { lane?: GeoLane; timeoutMs?: number; locality?: boolean },
): Promise<{ name: string | null; address: string | null }> {
lng = onTheRealWorld(lng);
// Amap answers first when it holds the keyed slot, and only for a point it
// can possibly know: outside its box the call would cost a round trip to
// come back empty before Nominatim is asked anyway. Resolved at userId 0,
@@ -102,6 +102,8 @@ const UNNUMBERED = [
'Migration20200101046320_a_place_knows_where_its_details_came_from',
'Migration20200101046330_an_object_no_row_names_is_marked_before_it_goes',
'Migration20200101046340_a_released_object_outlasts_every_undo_before_it_goes',
'Migration20200101046350_a_place_lies_on_the_real_world',
'Migration20200101046360_a_new_install_starts_with_more_addons_on',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,74 @@
/**
* `Migration20200101046360_a_new_install_starts_with_more_addons_on`: a new
* install (no account yet) starts with Journey and Collections on; an existing
* install keeps every addon exactly as it was.
*/
import { DEFAULT_ADDONS } from '../../../src/db/seeders/AddonSeeder';
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101046360_a_new_install_starts_with_more_addons_on';
interface AddonRow {
id: string;
enabled: number;
}
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const idx = names.indexOf(TARGET);
expect(idx).toBeGreaterThan(0);
await migrateTo(orm, names[idx - 1]);
return orm;
}
const addons = (orm: MikroORM) => rawQuery<AddonRow>(orm, 'SELECT id, enabled FROM addons ORDER BY id');
describe('a new install starts with more addons on', () => {
it('ADDON-NEW-001: a new install has Journey and Collections switched on and nothing else touched', async () => {
const orm = await ormBeforeTarget();
try {
const before = await addons(orm);
expect(before).toContainEqual({ id: 'journey', enabled: 0 });
expect(before).toContainEqual({ id: 'collections', enabled: 0 });
await migrateTo(orm, TARGET);
expect(await addons(orm)).toEqual(
before.map((row) => (row.id === 'journey' || row.id === 'collections' ? { ...row, enabled: 1 } : row)),
);
} finally {
await orm.close(true);
}
}, 30000);
it('ADDON-NEW-002: an existing install keeps every addon as it was', async () => {
const orm = await ormBeforeTarget();
try {
await rawExec(
orm,
`INSERT INTO users (id, username, email, password_hash) VALUES (1, 'admin', 'admin@test', 'x')`,
);
const before = await addons(orm);
await migrateTo(orm, TARGET);
expect(await addons(orm)).toEqual(before);
expect(before).toContainEqual({ id: 'journey', enabled: 0 });
expect(before).toContainEqual({ id: 'collections', enabled: 0 });
} finally {
await orm.close(true);
}
}, 30000);
it('ADDON-NEW-004: the seeder writes every trip addon but Road trip, Journey, Collections, Atlas, Vacay and Naver on', () => {
const enabled = (id: string) => DEFAULT_ADDONS.find((addon) => addon.id === id)?.enabled;
for (const addon of DEFAULT_ADDONS.filter((a) => a.type === 'trip')) {
expect([addon.id, addon.enabled]).toEqual([addon.id, addon.id === 'roadtrip' ? 0 : 1]);
}
for (const id of ['journey', 'collections', 'atlas', 'vacay', 'naver_list_import']) expect(enabled(id)).toBe(1);
});
});
@@ -0,0 +1,116 @@
/**
* `Migration20200101046350_a_place_lies_on_the_real_world`: a longitude saved
* from a repeated world copy (274.9 for -85.1, #2976) is moved back onto the
* real world in places, reservation endpoints, collection places and the
* places-index contribution outbox. Rows
* already in range, and places without coordinates, keep exactly what they had,
* and a second run changes nothing.
*/
import { Migration20200101046350_a_place_lies_on_the_real_world } from '../../../src/db/migrations/Migration20200101046350_a_place_lies_on_the_real_world';
import {
createMigrationOrm,
migrateTo,
pendingNames,
rawExec,
rawQuery,
runMigrationDirect,
} from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101046350_a_place_lies_on_the_real_world';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const idx = names.indexOf(TARGET);
expect(idx).toBeGreaterThan(0);
await migrateTo(orm, names[idx - 1]);
return orm;
}
async function seed(orm: MikroORM): Promise<void> {
await rawExec(orm, `INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')`);
await rawExec(orm, `INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'Pacific')`);
await rawExec(
orm,
`INSERT INTO places (id, trip_id, name, lat, lng) VALUES
(1, 1, 'right-hand copy', 10.14, 274.921875),
(2, 1, 'left-hand copy', 35.68, -220.3),
(3, 1, 'two copies away', 35.68, 859.7),
(4, 1, 'real world', 34.01, -118.5),
(5, 1, 'date line', -17.7, 180),
(6, 1, 'no coordinates', NULL, NULL)`,
);
await rawExec(orm, `INSERT INTO reservations (id, trip_id, title) VALUES (1, 1, 'LAX to AKL')`);
await rawExec(
orm,
`INSERT INTO reservation_endpoints (id, reservation_id, role, sequence, name, lat, lng) VALUES
(1, 1, 'from', 0, 'LAX', 33.94, -118.41),
(2, 1, 'to', 1, 'AKL', -37.01, -185.215)`,
);
await rawExec(orm, `INSERT INTO collections (id, owner_id, name) VALUES (1, 1, 'Saved')`);
await rawExec(
orm,
`INSERT INTO collection_places (id, collection_id, owner_id, name, lat, lng) VALUES
(1, 1, 1, 'saved on a copy', 48.85, 362.35),
(2, 1, 1, 'saved at home', 48.85, 2.35)`,
);
await rawExec(
orm,
`INSERT INTO place_contrib_outbox (id, contrib_key, field, value, place_id, place_name, lat, lng, cid, eligible_at, next_attempt_at)
VALUES (1, 'k1', 'website', 'x', 1, 'right-hand copy', 10.14, 274.921875, 'c1', '2026-01-01', '2026-01-01')`,
);
}
const lngs = async (orm: MikroORM, table: string) =>
(await rawQuery<{ id: number; lng: number | null }>(orm, `SELECT id, lng FROM ${table} ORDER BY id`)).map((r) => [
r.id,
r.lng === null ? null : Math.round(r.lng * 1e6) / 1e6,
]);
async function expectWrapped(orm: MikroORM): Promise<void> {
expect(await lngs(orm, 'places')).toEqual([
[1, -85.078125],
[2, 139.7],
[3, 139.7],
[4, -118.5],
[5, 180],
[6, null],
]);
expect(await lngs(orm, 'reservation_endpoints')).toEqual([
[1, -118.41],
[2, 174.785],
]);
expect(await lngs(orm, 'collection_places')).toEqual([
[1, 2.35],
[2, 2.35],
]);
expect(await lngs(orm, 'place_contrib_outbox')).toEqual([[1, -85.078125]]);
}
describe('a place lies on the real world migration', () => {
it('REALWORLD-MIG-001: moves longitudes from a world copy back, leaves the rest alone', async () => {
const orm = await ormBeforeTarget();
try {
await seed(orm);
await migrateTo(orm, TARGET);
await expectWrapped(orm);
} finally {
await orm.close(true);
}
}, 30000);
it('REALWORLD-MIG-002: running it again changes nothing', async () => {
const orm = await ormBeforeTarget();
try {
await seed(orm);
await migrateTo(orm, TARGET);
await runMigrationDirect(orm, Migration20200101046350_a_place_lies_on_the_real_world);
await expectWrapped(orm);
} finally {
await orm.close(true);
}
}, 30000);
});
@@ -0,0 +1,198 @@
/**
* Every write of a place, a booking endpoint or a saved collection place stores
* its longitude on the real world (#2976): a point from a repeated world copy
* (-220.3, 274.9) is kept as the same point in [-180, 180], whichever transport
* wrote it, since REST, MCP, plugin RPC and the importers all end here. A value
* already in range and a missing one are written as they came.
*/
import { CollectionPlaces } from '../../../../src/db/entities/CollectionPlaces.entity';
import { Places } from '../../../../src/db/entities/Places.entity';
import { ReservationEndpoints } from '../../../../src/db/entities/ReservationEndpoints.entity';
import type { CollectionPlacesRepository } from '../../../../src/db/repositories/CollectionPlaces.repository';
import type { PlacesRepository } from '../../../../src/db/repositories/Places.repository';
import type { ReservationEndpointsRepository } from '../../../../src/db/repositories/ReservationEndpoints.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createPlace, createReservation, createTrip, createUser } from '../../../helpers/factories';
import { makeCollection } from '../../../helpers/factories/collections';
import { findRow } from '../../../helpers/factories/rows';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let places: PlacesRepository;
let endpoints: ReservationEndpointsRepository;
let collectionPlaces: CollectionPlacesRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
places = t.repo(Places);
endpoints = t.repo(ReservationEndpoints);
collectionPlaces = t.repo(CollectionPlaces);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
const placeWrite = {
description: null,
address: null,
category_id: null,
price: null,
currency: null,
place_time: null,
end_time: null,
notes: null,
image_url: null,
google_place_id: null,
google_ftid: null,
osm_id: null,
amap_poi_id: null,
website: null,
phone: null,
route_color: null,
stop_type: null,
fill_percent: null,
email: null,
opening_hours: null,
};
async function storedLng(id: number): Promise<number | null | undefined> {
return (await findRow(t.orm, Places, { id }))?.lng;
}
describe('longitude on write', () => {
it('LNGWRITE-001: insertPlace and updatePlace keep a point from a world copy on the real world', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const id = await places.insertPlace({
...placeWrite,
trip_id: trip.id,
name: 'Copy',
lat: 35.68,
lng: -220.3,
duration_minutes: 60,
transport_mode: 'walking',
route_geometry: null,
});
expect(await storedLng(id)).toBeCloseTo(139.7, 9);
await places.updatePlace(id, {
...placeWrite,
name: 'Copy',
lat: 10.14,
lng: 274.921875,
duration_minutes: 60,
transport_mode: null,
});
expect(await storedLng(id)).toBeCloseTo(-85.078125, 9);
await places.updatePlace(id, {
...placeWrite,
name: 'Copy',
lat: null,
lng: null,
duration_minutes: 60,
transport_mode: null,
});
expect(await storedLng(id)).toBeNull();
});
it('LNGWRITE-002: a place on the real world, the edges included, is written as it came', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const west = await places.insertTourPlace({
trip_id: trip.id,
name: 'West',
lat: 0,
lng: -118.4,
route_geometry: null,
});
const edge = await places.insertTourPlace({
trip_id: trip.id,
name: 'Edge',
lat: 0,
lng: 180,
route_geometry: null,
});
const otherEdge = await places.insertTourPlace({
trip_id: trip.id,
name: 'Other edge',
lat: 0,
lng: -180,
route_geometry: null,
});
expect([await storedLng(west), await storedLng(edge), await storedLng(otherEdge)]).toEqual([-118.4, 180, -180]);
});
it('LNGWRITE-003: tour places and their route rewrites wrap too', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tourId = await places.insertTourPlace({
trip_id: trip.id,
name: 'Ridge',
lat: 47,
lng: 371,
route_geometry: null,
});
expect(await storedLng(tourId)).toBeCloseTo(11, 9);
const place = createPlace(testDb, trip.id, { name: 'Old', lat: 1, lng: 1 });
expect(
await places.updateTourRoute(place.id, trip.id, { name: 'New', lat: 1, lng: -349, route_geometry: null }),
).toBe(true);
expect(await storedLng(place.id)).toBeCloseTo(11, 9);
});
it('LNGWRITE-004: a booking endpoint from a world copy is stored on the real world', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const reservation = createReservation(testDb, trip.id, { type: 'flight' });
const id = await endpoints.insertEndpoint({
reservation_id: reservation.id,
role: 'to',
sequence: 1,
name: 'AKL',
code: 'AKL',
lat: -37.01,
lng: -185.215,
timezone: null,
local_time: null,
local_date: null,
});
expect((await findRow(t.orm, ReservationEndpoints, { id }))?.lng).toBeCloseTo(174.785, 9);
});
it('LNGWRITE-005: a saved collection place wraps on save and on update, and an update without lng leaves it alone', async () => {
const { user } = createUser(testDb);
const collection = await makeCollection(t, user.id, { name: 'Saved' });
const id = await collectionPlaces.insertSavedPlace({
...placeWrite,
collection_id: collection.id,
owner_id: user.id,
saved_by: user.id,
name: 'Paris',
lat: 48.85,
lng: 362.35,
status: 'saved',
source_trip_id: null,
source_place_id: null,
links: null,
});
const lngOf = async () => (await findRow(t.orm, CollectionPlaces, { id }))?.lng;
expect(await lngOf()).toBeCloseTo(2.35, 9);
await collectionPlaces.updateFields(id, { name: 'Paris again' });
expect(await lngOf()).toBeCloseTo(2.35, 9);
await collectionPlaces.updateFields(id, { lng: -357.65 });
expect(await lngOf()).toBeCloseTo(2.35, 9);
});
});
+2 -1
View File
@@ -155,7 +155,8 @@ describe('Tool: create_budget_item', () => {
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('Hotel Paris');
expect(data.item.category).toBe('Accommodation');
// Stored as its fixed key, as the Costs tab shows it (#2835).
expect(data.item.category).toBe('accommodation');
expect(data.item.total_price).toBe(500);
expect(data.item.note).toBe('Prepaid');
});
+6 -6
View File
@@ -565,10 +565,10 @@ describe('Prompt: budget-overview', () => {
const client = await buildServer(user.id);
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
expect(text).toContain('Budget Trip');
expect(text).toContain('Transport');
expect(text).toContain('Accommodation');
expect(text).toContain('550'); // Transport total
expect(text).toContain('300'); // Accommodation total
// Totals group by the fixed Costs key, whatever case the expense was stored in (#2835).
expect(text).toContain('- transport: 250');
expect(text).toContain('- accommodation: 300');
expect(text).toContain('550');
});
it('prints a foreign-currency bill at the rate it was booked at, not as that many euros (#2525)', async () => {
@@ -588,8 +588,8 @@ describe('Prompt: budget-overview', () => {
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
// 801.76 USD at 1.17 is 685.26 EUR. Summed as stored it read "Total: 901.76 EUR".
expect(text).toContain('Total: 785.26 EUR');
expect(text).toContain('- Accommodation: 685.26 EUR');
expect(text).toContain('- Food: 100 EUR');
expect(text).toContain('- accommodation: 685.26 EUR');
expect(text).toContain('- food: 100 EUR');
expect(text).not.toContain('801.76');
});
+1 -1
View File
@@ -291,7 +291,7 @@ describe('MCP transit tools', () => {
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary, price: 230, budget_category: 'Transport' },
}),
) as { reservation: { id: number; metadata: string } };
expect((await findRow(orm, BudgetItems, { reservation: paid.reservation.id }))?.category).toBe('Transport');
expect((await findRow(orm, BudgetItems, { reservation: paid.reservation.id }))?.category).toBe('transport');
const free = parseToolResult(
await harness.client.callTool({
@@ -349,7 +349,8 @@ describe('Transport tools: the price link', () => {
const data = parseToolResult(result) as { reservation: { id: number } };
const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id });
expect(item).toMatchObject({ name: 'ZRH → CDG', category: 'Flights', total_price: 240 });
// Stored as its fixed Costs key (#2835).
expect(item).toMatchObject({ name: 'ZRH → CDG', category: 'flights', total_price: 240 });
// The price also rides along in the reservation metadata, as the REST path does.
expect(broadcastMock.mock.calls.some((c) => c[1] === 'budget:created')).toBe(true);
});
@@ -365,7 +366,8 @@ describe('Transport tools: the price link', () => {
});
const data = parseToolResult(result) as { reservation: { id: number } };
const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id });
expect(item?.category).toBe('train');
// The type is stored as its Costs key: a train is a transport (#2835).
expect(item?.category).toBe('transport');
});
});
@@ -301,3 +301,17 @@ describe('nearby helpers', () => {
expect(nearbyCacheKey(52.5163, 13.3777, 500, 20, 'de')).not.toBe(nearbyCacheKey(52.5163, 13.3777, 500, 20, 'en'));
});
});
describe('MapsService.reverseGeocode on a repeated world copy (#2976)', () => {
it('MAPS-NEARBY-COPY-002: a right-click on a world copy is reverse geocoded on the real world, a real point as it came', async () => {
const svc = make();
const osm = (svc as unknown as { osm: { reverse: (...args: unknown[]) => Promise<unknown> } }).osm;
const reverse = vi.spyOn(osm, 'reverse').mockResolvedValue({ name: 'Manzanillo', address: null });
expect(await svc.reverseGeocode('10.14', '274.921875')).toEqual({ name: 'Manzanillo', address: null });
expect(reverse).toHaveBeenLastCalledWith('10.14', '-85.078125', undefined, undefined);
await svc.reverseGeocode('48.85', '2.3500');
expect(reverse).toHaveBeenLastCalledWith('48.85', '2.3500', undefined, undefined);
});
});
+40
View File
@@ -0,0 +1,40 @@
// COST-CATEGORY-001 to COST-CATEGORY-005: a typed category read as one of the fixed keys.
import { COST_CATEGORY_HINT, costCategoryOf, storedCostCategory } from './cost-category';
import { describe, expect, it } from 'vitest';
describe('costCategoryOf', () => {
it('COST-CATEGORY-001: a key stays itself', () => {
expect(costCategoryOf('food')).toBe('food');
expect(costCategoryOf('accommodation')).toBe('accommodation');
});
it('COST-CATEGORY-002: another case or a common name reads as its key', () => {
expect(costCategoryOf('Food')).toBe('food');
expect(costCategoryOf(' FOOD ')).toBe('food');
expect(costCategoryOf('Flight')).toBe('flights');
expect(costCategoryOf('Hotel')).toBe('accommodation');
expect(costCategoryOf('Car park')).toBe('parking');
});
it('COST-CATEGORY-003: anything else is no key', () => {
expect(costCategoryOf('Comida')).toBeNull();
expect(costCategoryOf('')).toBeNull();
expect(costCategoryOf(null)).toBeNull();
expect(costCategoryOf(undefined)).toBeNull();
});
it('COST-CATEGORY-004: an expense is stored under the key, other text as given, nothing as other', () => {
expect(storedCostCategory('Food')).toBe('food');
expect(storedCostCategory('Train')).toBe('transport');
expect(storedCostCategory('Comida')).toBe('Comida');
expect(storedCostCategory('')).toBe('other');
expect(storedCostCategory(null)).toBe('other');
expect(storedCostCategory(undefined)).toBe('other');
});
it('COST-CATEGORY-005: the hint an assistant reads names every key', () => {
for (const key of ['accommodation', 'food', 'transport', 'flights', 'other'])
expect(COST_CATEGORY_HINT).toContain(key);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { COST_CATEGORIES, type CostCategory } from './budget.schema';
/**
* Free-text and legacy categories (and booking type labels) mapped to the fixed
* keys. Bookings used to store labels like "Flight"/"Train"/"Other", and an
* assistant writing through the MCP says "Food" where the key is "food".
*/
const CATEGORY_ALIASES: Record<string, CostCategory> = {
flight: 'flights',
flights: 'flights',
plane: 'flights',
flug: 'flights',
train: 'transport',
bus: 'transport',
car: 'transport',
'car rental': 'transport',
ferry: 'transport',
boat: 'transport',
taxi: 'transport',
transfer: 'transport',
cable_car: 'transport',
transport: 'transport',
transportation: 'transport',
hotel: 'accommodation',
accommodation: 'accommodation',
lodging: 'accommodation',
hostel: 'accommodation',
restaurant: 'food',
food: 'food',
dining: 'food',
meal: 'food',
meals: 'food',
grocery: 'groceries',
groceries: 'groceries',
activity: 'activities',
activities: 'activities',
sightseeing: 'sightseeing',
sights: 'sightseeing',
shop: 'shopping',
shopping: 'shopping',
fee: 'fees',
fees: 'fees',
health: 'health',
medical: 'health',
tip: 'tips',
tips: 'tips',
gas: 'fuel',
fuel: 'fuel',
petrol: 'fuel',
parking: 'parking',
parkings: 'parking',
'car park': 'parking',
other: 'other',
misc: 'other',
};
const KEYS: ReadonlySet<string> = new Set(COST_CATEGORIES);
/**
* The fixed key a stored or typed category stands for, whatever its case, or
* null for a category that is none of them.
*/
export function costCategoryOf(category: string | null | undefined): CostCategory | null {
if (!category) return null;
if (KEYS.has(category)) return category as CostCategory;
return CATEGORY_ALIASES[category.trim().toLowerCase()] ?? null;
}
/**
* The category an expense is stored under: the fixed key when the text names one,
* so "Food" and "food" are one category, and the text itself otherwise.
*/
export function storedCostCategory(category: string | null | undefined): string {
return costCategoryOf(category) ?? (category || 'other');
}
/** What the MCP tells an assistant about the category field. */
export const COST_CATEGORY_HINT = `One of the Costs categories: ${COST_CATEGORIES.join(', ')}. Another case or a common name (Hotel, Flight, Food) is stored as its key; any other text is kept as given and shows as other`;
+31
View File
@@ -120,6 +120,37 @@ describe('the hotels at both ends of a day', () => {
});
});
it('STAY-BOOKENDS-007: a night away on a side trip wakes up in the side trip and comes back to the room it kept', () => {
// The first hotel stays booked over the whole trip and comes first in the list; the
// side trip's hotel holds one night in between.
const kept = stay(1, 10, 40, [13.7, 100.5]);
const away = stay(2, 20, 30, [18.8, 99]);
const list = [kept, away];
// Day 2 leaves the kept room and sleeps away.
expect(getDayBookendHotels(d2, days, list)).toEqual({
morning: kept,
evening: away,
morningIsSleptHere: true,
eveningIsOvernight: true,
});
// Day 3 wakes up away and goes back to the kept room for the night.
expect(getDayBookendHotels(d3, days, list)).toEqual({
morning: away,
evening: kept,
morningIsSleptHere: true,
eveningIsOvernight: true,
});
// The order of the list no longer decides it.
expect(getDayBookendHotels(d3, days, [away, kept])).toEqual(getDayBookendHotels(d3, days, list));
// Day 4, the kept room's check-out day, wakes up there.
expect(getDayBookendHotels(d4, days, list)).toEqual({
morning: kept,
evening: kept,
morningIsSleptHere: true,
eveningIsOvernight: false,
});
});
it('STAY-BOOKENDS-005: a stay without coordinates is left out, and the other stay answers on its own', () => {
const unplaced = stay(1, 10, 20, null);
const next = stay(2, 20, 30, [9, 9]);
+13 -3
View File
@@ -102,17 +102,27 @@ export const getDayBookendHotels = <A extends StayRange>(
return d ? getDayOrder(d, days) : dayOrd;
};
const checkIn = inRange.find((a) => a.start_day_id === day.id); // the hotel you arrive at tonight
const sleptHere = inRange.find((a) => orderOf(a.start_day_id) < dayOrd); // the hotel you woke up in
// Of the stays checked into on an earlier day, the latest is the one you woke up in: a
// night away on a side trip while the first room stays booked sleeps in the side trip's
// hotel, not in the one booked first. The earliest of them used to win, so the morning
// after drove from the first hotel to the side trip's airport. Ties keep the list order.
const before = inRange
.filter((a) => orderOf(a.start_day_id) < dayOrd)
.sort((a, b) => orderOf(b.start_day_id) - orderOf(a.start_day_id));
const sleptHere = before[0];
// Tonight's hotel without a check-in today: a stay that runs on past today, the latest
// first, which brings the side trip back to the room that was kept.
const continuing = before.find((a) => orderOf(a.end_day_id) > dayOrd);
return {
morning: sleptHere ?? checkIn ?? inRange[0],
evening: checkIn ?? sleptHere ?? inRange[0],
evening: checkIn ?? continuing ?? sleptHere ?? inRange[0],
// Provenance for the drawing consumers (map + sidebar). A hotel↔transport bookend
// is only real when you actually used the hotel: morningIsSleptHere is true only
// when you woke up there (not a check-in fallback on an arrival day), and
// eveningIsOvernight is true only when you sleep there tonight (you check in today,
// or an earlier stay continues past today). The optimizer keeps using the values.
morningIsSleptHere: sleptHere != null,
eveningIsOvernight: checkIn != null || (sleptHere != null && orderOf(sleptHere.end_day_id) > dayOrd),
eveningIsOvernight: checkIn != null || continuing != null,
};
};
+29
View File
@@ -0,0 +1,29 @@
// LONGITUDE-001 to LONGITUDE-004: longitudes from a repeated world copy back onto the real one (#2976).
import { wrapLongitude, wrapOptionalLongitude } from './longitude';
import { describe, expect, it } from 'vitest';
describe('wrapLongitude', () => {
it('LONGITUDE-001: leaves a real longitude alone, the edges included', () => {
for (const lng of [0, 13.4, -118.4, 174.8, 180, -180]) expect(wrapLongitude(lng)).toBe(lng);
});
it('LONGITUDE-002: brings a point on a neighbouring copy back to where it is', () => {
expect(wrapLongitude(274.921875)).toBeCloseTo(-85.078125, 9);
expect(wrapLongitude(-220)).toBeCloseTo(140, 9);
expect(wrapLongitude(139.7 - 720)).toBeCloseTo(139.7, 9);
expect(wrapLongitude(360)).toBe(0);
expect(Object.is(wrapLongitude(-360), 0)).toBe(true);
});
it('LONGITUDE-003: hands a non-finite value back for the caller to reject', () => {
expect(wrapLongitude(Number.NaN)).toBeNaN();
expect(wrapLongitude(Number.POSITIVE_INFINITY)).toBe(Number.POSITIVE_INFINITY);
});
it('LONGITUDE-004: passes null and undefined through for optional columns', () => {
expect(wrapOptionalLongitude(null)).toBeNull();
expect(wrapOptionalLongitude(undefined)).toBeUndefined();
expect(wrapOptionalLongitude(-220)).toBeCloseTo(140, 9);
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* A longitude brought back onto the one real world, in [-180, 180].
*
* A map that repeats the world sideways (Leaflet and MapLibre both do) reports a
* point on a neighbouring copy as, say, -220 or 274.9 instead of 140 or -85.1.
* Stored like that, a place sat on its copy for good: the geocoder found nothing
* there, and a route to a place on the real world ran across the whole map (#2976).
*
* The edges are kept as given, so a stored -180 or 180 does not flip sides. A
* value that is not a finite number is returned untouched, for the caller's own
* validation to deal with.
*/
export function wrapLongitude(lng: number): number {
if (!Number.isFinite(lng) || (lng >= -180 && lng <= 180)) return lng;
const wrapped = ((((lng + 180) % 360) + 360) % 360) - 180;
// -0 reads as 0 everywhere else; keep it out of storage and snapshots.
return wrapped === 0 ? 0 : wrapped;
}
/** wrapLongitude for an optional column: null and undefined pass through. */
export function wrapOptionalLongitude<T extends number | null | undefined>(lng: T): T {
return (typeof lng === 'number' ? wrapLongitude(lng) : lng) as T;
}
+2 -2
View File
@@ -3182,7 +3182,7 @@ const help: TranslationStrings = {
'help.ctx.trip-bookings.summary':
'يضم هذا التبويب كل ما حُجز للرحلة عدا المواصلات: أماكن الإقامة، وحجوزات الطاولات، والتذاكر، والجولات، ومواقف السيارات. كل حجز يظهر كبطاقة أو صف في القائمة أو شريط على المخطط الزمني، مع رمزه ومستنداته ومسافريه وتكلفته. انقر على حجز لفتح تفاصيله.',
'help.ctx.trip-bookings.bullet.1':
'يفتح «حجز يدوي» في الطرف الأيمن من الشريط «حجز جديد». يمكنك إنشاء ستة أنواع من الحجوزات: «الإقامة» و«مطعم» و«فعالية» و«جولة» و«موقف سيارات» و«أخرى». أما الرحلات الجوية والقطارات ووسائل النقل الأخرى فتجدها في تبويب «المواصلات»، ولا تظهر هنا أبدًا.',
'يفتح «حجز يدوي» في الطرف الأيمن من الشريط «حجز جديد». يمكنك إنشاء ستة أنواع من الحجوزات: «الإقامة» و«مطعم» و«فعالية» و«جولة مع مرشد» و«موقف سيارات» و«أخرى». أما الرحلات الجوية والقطارات ووسائل النقل الأخرى فتجدها في تبويب «المواصلات»، ولا تظهر هنا أبدًا.',
'help.ctx.trip-bookings.bullet.2':
'أيقونة التنزيل قبل «حجز يدوي»، «استيراد تأكيدات الحجز»، ترفع التأكيدات ليقرأها TREK تلقائيًا: EML أو PDF أو PKPass أو HTML أو TXT، حتى خمسة ملفات بحد أقصى 10 ميغابايت لكل منها، والصور إذا كان نموذج الذكاء الاصطناعي يقرأ الصور. ولا تظهر الأيقونة إلا إذا كان الخادم قادرًا على قراءتها.',
'help.ctx.trip-bookings.bullet.3':
@@ -3221,7 +3221,7 @@ const help: TranslationStrings = {
'help.guide.create-booking.goal': 'أضف إلى الرحلة يدويًا مطعمًا أو فعالية أو جولة أو موقف سيارات أو أي شيء آخر.',
'help.guide.create-booking.step.1': 'انقر «حجز يدوي» في طرف الشريط. تفتح نافذة «حجز جديد» وفي رأسها العنوان وشارتان.',
'help.guide.create-booking.step.2':
'انقر على تسمية النوع في الشريط العلوي، وتظهر فيها «أخرى» في الحجز الجديد، ثم اختر «نوع الحجز». ينشئ هذا التبويب ستة أنواع: «الإقامة» و«مطعم» و«فعالية» و«جولة» و«موقف سيارات» و«أخرى». ويتغير النموذج حسب اختيارك: «الإقامة» وحدها تستخدم نطاقًا من الأيام بدل التواريخ.',
'انقر على تسمية النوع في الشريط العلوي، وتظهر فيها «أخرى» في الحجز الجديد، ثم اختر «نوع الحجز». ينشئ هذا التبويب ستة أنواع: «الإقامة» و«مطعم» و«فعالية» و«جولة مع مرشد» و«موقف سيارات» و«أخرى». ويتغير النموذج حسب اختيارك: «الإقامة» وحدها تستخدم نطاقًا من الأيام بدل التواريخ.',
'help.guide.create-booking.step.3':
'اكتب العنوان في الشريط العلوي، في الحقل فوق الشارات. إنه الحقل الإلزامي الوحيد، ويبقى «إضافة» رماديًا إلى أن تكتب فيه شيئًا.',
'help.guide.create-booking.step.4':
+1 -1
View File
@@ -60,7 +60,7 @@ const reservations: TranslationStrings = {
'reservations.type.car': 'سيارة',
'reservations.type.cruise': 'رحلة بحرية',
'reservations.type.event': 'فعالية',
'reservations.type.tour': 'جولة',
'reservations.type.tour': 'جولة مع مرشد',
'reservations.type.parking': 'موقف سيارات',
'reservations.type.other': 'أخرى',
'reservations.type.bus': 'حافلة',
+1 -1
View File
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
'tours.mode.label': 'عرض الإضافة',
'tours.title': 'الجولات',
'tours.empty.title': 'لا توجد جولات بعد',
'tours.empty.body': 'خطّط لجولة أو استورد ملف GPX أو KML للبدء.',
'tours.empty.body': 'خطّط لجولة أو استورد ملف GPX أو KML للبدء. حجزت جولة مع مرشد؟ مكانها في الحجوزات.',
'tours.addToDay': 'إضافة إلى يوم',
'tours.badge.wanderer': 'wanderer',
'tours.badge.gpx': 'استيراد GPX',
+2 -2
View File
@@ -3145,7 +3145,7 @@ const help: TranslationStrings = {
'help.ctx.trip-bookings.summary':
'Bu tabda nəqliyyatdan başqa səyahət üçün rezerv olunan hər şey saxlanılır: qalacağınız yerlər, masalar, biletlər, turlar və parkinq. Hər rezervasiya kodu, sənədləri, səyahətçiləri və xərci ilə birlikdə kart, siyahı sətri və ya zaman xəttində zolaq kimi görünür. Təfərrüatları açmaq üçün rezervasiyaya klikləyin.',
'help.ctx.trip-bookings.bullet.1':
'Panelin sağ ucundakı «Əl ilə rezervasiya et» «Yeni rezervasiya» pəncərəsini açır. Burada altı növ rezervasiya yaradılır: Yaşayış yeri, Restoran, Tədbir, Tur, Parkinq və Digər. Uçuşlar, qatarlar və digər nəqliyyat «Nəqliyyat» bölməsindədir və burada heç vaxt görünmür.',
'Panelin sağ ucundakı «Əl ilə rezervasiya et» «Yeni rezervasiya» pəncərəsini açır. Burada altı növ rezervasiya yaradılır: Yaşayış yeri, Restoran, Tədbir, Bələdçili tur, Parkinq və Digər. Uçuşlar, qatarlar və digər nəqliyyat «Nəqliyyat» bölməsindədir və burada heç vaxt görünmür.',
'help.ctx.trip-bookings.bullet.2':
'«Əl ilə rezervasiya et» qarşısındakı endirmə işarəsi, «Rezervasiya təsdiqlərini idxal et», təsdiqləri yükləyir ki, TREK onları avtomatik oxusun: EML, PDF, PKPass, HTML və ya TXT, ən çoxu 10 MB-lıq beş fayl, AI modeli şəkilləri oxuya bilirsə, fotolar da. Bu işarə yalnız server onları oxuya bildikdə görünür.',
'help.ctx.trip-bookings.bullet.3':
@@ -3187,7 +3187,7 @@ const help: TranslationStrings = {
'help.guide.create-booking.step.1':
'Panelin sağ ucundakı «Əl ilə rezervasiya et» düyməsinə klikləyin. «Yeni rezervasiya» açılır, yuxarıdakı başlıqda ad və iki nişan olur.',
'help.guide.create-booking.step.2':
'Yuxarı zolaqdakı növ yazısına klikləyin (yeni rezervasiyada orada «Digər» yazılır) və «Rezervasiya növü»nü seçin. Bu bölmədə altı növ yaradılır: «Yaşayış yeri», «Restoran», «Tədbir», «Tur», «Parkinq» və «Digər». Forma seçiminizə görə dəyişir: yalnız «Yaşayış yeri» tarixlər əvəzinə günlər aralığından istifadə edir.',
'Yuxarı zolaqdakı növ yazısına klikləyin (yeni rezervasiyada orada «Digər» yazılır) və «Rezervasiya növü»nü seçin. Bu bölmədə altı növ yaradılır: «Yaşayış yeri», «Restoran», «Tədbir», «Bələdçili tur», «Parkinq» və «Digər». Forma seçiminizə görə dəyişir: yalnız «Yaşayış yeri» tarixlər əvəzinə günlər aralığından istifadə edir.',
'help.guide.create-booking.step.3':
'Başlığı yuxarıdakı zolağa, nişanların üstündəki xanaya yazın. Bu, yeganə məcburi xanadır və nəsə yazmayana qədər «Əlavə et» boz qalır.',
'help.guide.create-booking.step.4':
+1 -1
View File
@@ -72,7 +72,7 @@ const reservations: TranslationStrings = {
'reservations.type.car': 'Avtomobil',
'reservations.type.cruise': 'Kruiz',
'reservations.type.event': 'Tədbir',
'reservations.type.tour': 'Tur',
'reservations.type.tour': 'Bələdçili tur',
'reservations.type.parking': 'Parkinq',
'reservations.type.other': 'Digər',
'reservations.type.bus': 'Avtobus',
+2 -1
View File
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
'tours.mode.label': 'Əlavə etmə görünüşü',
'tours.title': 'Turlar',
'tours.empty.title': 'Hələ tur yoxdur',
'tours.empty.body': 'Başlamaq üçün tur planlaşdırın və ya GPX ya KML faylı idxal edin.',
'tours.empty.body':
'Başlamaq üçün tur planlaşdırın və ya GPX ya KML faylı idxal edin. Bələdçili tur sifariş etmisiniz? Onun yeri Rezervasiyalar bölməsidir.',
'tours.addToDay': 'Günə əlavə et',
'tours.badge.wanderer': 'wanderer',
'tours.badge.gpx': 'GPX idxalı',
+2 -2
View File
@@ -3419,7 +3419,7 @@ const help: TranslationStrings = {
'help.ctx.trip-bookings.summary':
'Esta aba reúne tudo o que foi reservado para a viagem, menos transporte: hospedagens, mesas, ingressos, passeios e estacionamentos. Cada reserva é um cartão, uma linha de lista ou uma barra na linha do tempo, com código, documentos, viajantes e custo. Clique em uma reserva para abrir os detalhes.',
'help.ctx.trip-bookings.bullet.1':
'“Reserva manual”, na ponta direita da barra, abre “Nova reserva”. Ela cria seis tipos de reserva: Hospedagem, Restaurante, Evento, Passeio, Estacionamento e Outro. Voos, trens e outros transportes ficam na aba Transportes e nunca aparecem aqui.',
'“Reserva manual”, na ponta direita da barra, abre “Nova reserva”. Ela cria seis tipos de reserva: Hospedagem, Restaurante, Evento, Passeio guiado, Estacionamento e Outro. Voos, trens e outros transportes ficam na aba Transportes e nunca aparecem aqui.',
'help.ctx.trip-bookings.bullet.2':
'O ícone de download antes de “Reserva manual”, “Importar confirmações de reserva”, envia confirmações para o TREK ler automaticamente: EML, PDF, PKPass, HTML ou TXT, no máximo cinco arquivos de 10 MB, e fotos quando o modelo de IA lê imagens. O ícone só aparece quando o servidor consegue ler esses arquivos.',
'help.ctx.trip-bookings.bullet.3':
@@ -3461,7 +3461,7 @@ const help: TranslationStrings = {
'help.guide.create-booking.step.1':
'Clique em “Reserva manual”, na ponta direita da barra. Abre “Nova reserva”, com o título e dois selos num cabeçalho no topo.',
'help.guide.create-booking.step.2':
'Clique no rótulo do tipo na faixa do topo, que mostra “Outro” numa reserva nova, e escolha o “Tipo de reserva”. Esta aba cria seis tipos: Hospedagem, Restaurante, Evento, Passeio, Estacionamento e Outro. O formulário muda conforme a escolha: só Hospedagem usa um intervalo de dias em vez de datas.',
'Clique no rótulo do tipo na faixa do topo, que mostra “Outro” numa reserva nova, e escolha o “Tipo de reserva”. Esta aba cria seis tipos: Hospedagem, Restaurante, Evento, Passeio guiado, Estacionamento e Outro. O formulário muda conforme a escolha: só Hospedagem usa um intervalo de dias em vez de datas.',
'help.guide.create-booking.step.3':
'Digite o título na faixa do topo, o campo acima das etiquetas. É o único campo obrigatório, e “Adicionar” fica cinza até você preencher algo.',
'help.guide.create-booking.step.4':
+1 -1
View File
@@ -60,7 +60,7 @@ const reservations: TranslationStrings = {
'reservations.type.car': 'Carro',
'reservations.type.cruise': 'Cruzeiro',
'reservations.type.event': 'Evento',
'reservations.type.tour': 'Passeio',
'reservations.type.tour': 'Passeio guiado',
'reservations.type.parking': 'Estacionamento',
'reservations.type.other': 'Outro',
'reservations.type.bus': 'Ônibus',
+2 -1
View File
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
'tours.mode.label': 'Adicionar visualização',
'tours.title': 'Passeios',
'tours.empty.title': 'Nenhum passeio ainda',
'tours.empty.body': 'Planeje um passeio ou importe um arquivo GPX ou KML para começar.',
'tours.empty.body':
'Planeje um passeio ou importe um arquivo GPX ou KML para começar. Reservou um passeio guiado? Ele fica em Reservas.',
'tours.addToDay': 'Adicionar a um dia',
'tours.badge.wanderer': 'wanderer',
'tours.badge.gpx': 'Importação GPX',
+2 -2
View File
@@ -3467,7 +3467,7 @@ const help: TranslationStrings = {
'help.ctx.trip-bookings.summary':
"Aquesta pestanya conté tot el que s'ha reservat per al viatge excepte el transport: allotjaments, taules, entrades, excursions i aparcaments. Cada reserva és una targeta, una fila de la llista o una barra de la cronologia, amb el seu codi, els documents, els viatgers i el cost. Fes clic a una reserva per obrir-ne els detalls.",
'help.ctx.trip-bookings.bullet.1':
'«Reserva manual», a l’extrem dret de la barra, obre «Reserva nova». Pots crear sis tipus de reserves: «Allotjament», «Restaurant», «Esdeveniment», «Excursió», «Aparcament» i «Altres». Els vols, els trens i la resta de transports són a la pestanya «Transports» i aquí no apareixen mai.',
'«Reserva manual», a l’extrem dret de la barra, obre «Reserva nova». Pots crear sis tipus de reserves: «Allotjament», «Restaurant», «Esdeveniment», «Visita guiada», «Aparcament» i «Altres». Els vols, els trens i la resta de transports són a la pestanya «Transports» i aquí no apareixen mai.',
'help.ctx.trip-bookings.bullet.2':
"La icona de baixada de davant de «Reserva manual», «Importa confirmacions de reserva», puja confirmacions perquè TREK les llegeixi automàticament: EML, PDF, PKPass, HTML o TXT, fins a cinc fitxers de 10 MB com a màxim, i fotos si el model d'IA llegeix imatges. La icona només apareix si el servidor les pot llegir.",
'help.ctx.trip-bookings.bullet.3':
@@ -3509,7 +3509,7 @@ const help: TranslationStrings = {
'help.guide.create-booking.step.1':
"Fes clic a «Reserva manual», a l'extrem dret de la barra. S'obre «Reserva nova», amb el títol i dues píndoles a la capçalera.",
'help.guide.create-booking.step.2':
'Fes clic a l’etiqueta del tipus a la franja superior, que en una reserva nova diu «Altres», i tria el «Tipus de reserva». Des d’aquesta pestanya pots crear sis tipus: «Allotjament», «Restaurant», «Esdeveniment», «Excursió», «Aparcament» i «Altres». El formulari canvia segons el que triïs: només «Allotjament» fa servir un interval de dies en lloc de dates.',
'Fes clic a l’etiqueta del tipus a la franja superior, que en una reserva nova diu «Altres», i tria el «Tipus de reserva». Des d’aquesta pestanya pots crear sis tipus: «Allotjament», «Restaurant», «Esdeveniment», «Visita guiada», «Aparcament» i «Altres». El formulari canvia segons el que triïs: només «Allotjament» fa servir un interval de dies en lloc de dates.',
'help.guide.create-booking.step.3':
'Escriu el títol a la franja superior, al camp que hi ha sobre les etiquetes. És l’únic camp obligatori, i «Afegir» està desactivat fins que hi escriguis alguna cosa.',
'help.guide.create-booking.step.4':
+1 -1
View File
@@ -29,7 +29,7 @@ const reservations: TranslationStrings = {
'reservations.type.car': 'Cotxe',
'reservations.type.cruise': 'Creuer',
'reservations.type.event': 'Esdeveniment',
'reservations.type.tour': 'Excursió',
'reservations.type.tour': 'Visita guiada',
'reservations.type.parking': 'Aparcament',
'reservations.type.other': 'Altres',
'reservations.type.bus': 'Autobús',
+2 -1
View File
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
'tours.mode.label': 'Afegeix una vista',
'tours.title': 'Excursions',
'tours.empty.title': 'Encara no hi ha excursions',
'tours.empty.body': 'Planifica una excursió o importa un fitxer GPX o KML per començar.',
'tours.empty.body':
'Planifica una excursió o importa un fitxer GPX o KML per començar. Has reservat una visita guiada? Va a Reserves.',
'tours.addToDay': 'Afegeix a un dia',
'tours.badge.wanderer': 'wanderer',
'tours.badge.gpx': 'Importació GPX',
+2 -2
View File
@@ -3345,7 +3345,7 @@ const help: TranslationStrings = {
'help.ctx.trip-bookings.summary':
'Tato karta obsahuje vše, co je pro cestu zarezervované, kromě dopravy: ubytování, stoly, vstupenky, prohlídky a parkování. Každá rezervace je kartou, řádkem seznamu nebo pruhem na časové ose s kódem, dokumenty, cestujícími a cenou. Kliknutím na rezervaci otevřete její podrobnosti.',
'help.ctx.trip-bookings.bullet.1':
'„Ruční rezervace“ na pravém konci lišty otevře „Nová rezervace“. Vytvoříte tak šest druhů rezervací: Ubytování, Restaurace, Událost, Prohlídka, Parkování a Ostatní. Lety, vlaky a další doprava jsou na kartě „Doprava“ a tady se nikdy nezobrazí.',
'„Ruční rezervace“ na pravém konci lišty otevře „Nová rezervace“. Vytvoříte tak šest druhů rezervací: Ubytování, Restaurace, Událost, Prohlídka s průvodcem, Parkování a Ostatní. Lety, vlaky a další doprava jsou na kartě „Doprava“ a tady se nikdy nezobrazí.',
'help.ctx.trip-bookings.bullet.2':
'Ikona stažení před „Ruční rezervace“, „Importovat potvrzení rezervace“, nahraje potvrzení, aby je TREK mohl automaticky přečíst: EML, PDF, PKPass, HTML nebo TXT, nejvýše pět souborů po 10 MB, a fotky, pokud model AI umí číst obrázky. Ikona se zobrazuje, jen když je server umí číst.',
'help.ctx.trip-bookings.bullet.3':
@@ -3387,7 +3387,7 @@ const help: TranslationStrings = {
'help.guide.create-booking.step.1':
'Klikněte na „Ruční rezervace“ na pravém konci lišty. Otevře se „Nová rezervace“ se záhlavím nahoře, ve kterém je název a dva štítky.',
'help.guide.create-booking.step.2':
'Klikněte na štítek typu v horním pruhu, u nové rezervace je na něm „Ostatní“, a vyberte „Typ rezervace“. Na této kartě vytvoříte šest typů: Ubytování, Restaurace, Událost, Prohlídka, Parkování a Ostatní. Formulář se podle volby změní: jen Ubytování používá místo dat rozsah dnů.',
'Klikněte na štítek typu v horním pruhu, u nové rezervace je na něm „Ostatní“, a vyberte „Typ rezervace“. Na této kartě vytvoříte šest typů: Ubytování, Restaurace, Událost, Prohlídka s průvodcem, Parkování a Ostatní. Formulář se podle volby změní: jen Ubytování používá místo dat rozsah dnů.',
'help.guide.create-booking.step.3':
'Napište název do pruhu nahoře, do pole nad štítky. Je to jediné povinné pole a tlačítko „Přidat“ zůstane šedé, dokud něco nevyplníte.',
'help.guide.create-booking.step.4':
+1 -1
View File
@@ -60,7 +60,7 @@ const reservations: TranslationStrings = {
'reservations.type.car': 'Auto',
'reservations.type.cruise': 'Plavba',
'reservations.type.event': 'Událost',
'reservations.type.tour': 'Prohlídka',
'reservations.type.tour': 'Prohlídka s průvodcem',
'reservations.type.parking': 'Parkování',
'reservations.type.other': 'Jiné',
'reservations.type.bus': 'Autobus',
+2 -1
View File
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
'tours.mode.label': 'Zobrazení pro přidávání',
'tours.title': 'Výlety',
'tours.empty.title': 'Zatím žádné výlety',
'tours.empty.body': 'Začněte naplánováním výletu nebo importem souboru GPX či KML.',
'tours.empty.body':
'Začněte naplánováním výletu nebo importem souboru GPX či KML. Zarezervovali jste prohlídku s průvodcem? Ta patří do Rezervací.',
'tours.addToDay': 'Přidat ke dni',
'tours.badge.wanderer': 'wanderer',
'tours.badge.gpx': 'Import GPX',
+2 -2
View File
@@ -3492,7 +3492,7 @@ const help: TranslationStrings = {
'help.ctx.trip-bookings.summary':
'Hier steht alles, was für die Reise gebucht ist, außer Transporten: Unterkünfte, Tischreservierungen, Tickets, Touren und Parkplätze. Jede Buchung ist eine Karte, eine Listenzeile oder ein Balken im Zeitstrahl, mit Code, Dokumenten, Reisenden und Kosten. Klick auf eine Buchung, um ihre Details zu öffnen.',
'help.ctx.trip-bookings.bullet.1':
'„Manuelle Buchung“ rechts in der Leiste öffnet „Neue Buchung“. Damit legst du sechs Arten von Buchungen an: „Unterkunft“, „Restaurant“, „Veranstaltung“, „Tour“, „Parken“ und „Andere“. Flüge, Züge und andere Verkehrsmittel gehören in den Tab „Transporte“ und erscheinen hier nie.',
'„Manuelle Buchung“ rechts in der Leiste öffnet „Neue Buchung“. Damit legst du sechs Arten von Buchungen an: „Unterkunft“, „Restaurant“, „Veranstaltung“, „Geführte Tour“, „Parken“ und „Andere“. Flüge, Züge und andere Verkehrsmittel gehören in den Tab „Transporte“ und erscheinen hier nie.',
'help.ctx.trip-bookings.bullet.2':
'Das Download-Symbol vor „Manuelle Buchung“, „Buchungsbestätigungen importieren“, gibt Bestätigungen an den Parser weiter: EML, PDF, PKPass, HTML oder TXT, höchstens fünf Dateien mit je bis zu 10 MB, und Fotos, wenn das KI-Modell Bilder lesen kann. Das Symbol erscheint nur, wenn der Server die Dateien auslesen kann.',
'help.ctx.trip-bookings.bullet.3':
@@ -3534,7 +3534,7 @@ const help: TranslationStrings = {
'help.guide.create-booking.step.1':
'Klick rechts in der Leiste auf „Manuelle Buchung“. „Neue Buchung“ öffnet sich, oben mit dem Titel und zwei Labels in einer Kopfzeile.',
'help.guide.create-booking.step.2':
'Klick oben in der Kopfzeile auf das Typ-Label, das bei einer neuen Buchung „Andere“ zeigt, und wähle die „Art der Buchung“. In diesem Tab legst du sechs Typen an: Unterkunft, Restaurant, Veranstaltung, Tour, Parken und Andere. Das Formular passt sich deiner Wahl an: Nur „Unterkunft“ nutzt einen Zeitraum aus Tagen statt einzelner Daten.',
'Klick oben in der Kopfzeile auf das Typ-Label, das bei einer neuen Buchung „Andere“ zeigt, und wähle die „Art der Buchung“. In diesem Tab legst du sechs Typen an: Unterkunft, Restaurant, Veranstaltung, Geführte Tour, Parken und Andere. Das Formular passt sich deiner Wahl an: Nur „Unterkunft“ nutzt einen Zeitraum aus Tagen statt einzelner Daten.',
'help.guide.create-booking.step.3':
'Gib den Titel oben in der Kopfzeile ein, im Feld über den Labels. Er ist das einzige Pflichtfeld, und „Hinzufügen“ bleibt ausgegraut, bis du etwas eingibst.',
'help.guide.create-booking.step.4':
+1 -1
View File
@@ -63,7 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.car': 'Auto',
'reservations.type.cruise': 'Kreuzfahrt',
'reservations.type.event': 'Veranstaltung',
'reservations.type.tour': 'Tour', // same-as-en
'reservations.type.tour': 'Geführte Tour',
'reservations.type.parking': 'Parkplatz',
'reservations.type.other': 'Sonstiges',
'reservations.type.bus': 'Bus', // same-as-en
+2 -1
View File
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
'tours.mode.label': 'Ansicht zum Hinzufügen',
'tours.title': 'Touren',
'tours.empty.title': 'Noch keine Touren',
'tours.empty.body': 'Plane eine Tour oder importiere eine GPX- oder KML-Datei.',
'tours.empty.body':
'Plane eine Tour oder importiere eine GPX- oder KML-Datei. Eine geführte Tour gebucht? Die gehört unter Buchungen.',
'tours.addToDay': 'Zum Tag hinzufügen',
'tours.badge.wanderer': 'wanderer',
'tours.badge.gpx': 'GPX-Import',
+2 -2
View File
@@ -3627,7 +3627,7 @@ const help: TranslationStrings = {
'help.ctx.trip-bookings.summary':
'This tab holds everything booked for the trip except transport: places to stay, tables, tickets, tours and parking. Every booking is a card, a list row or a bar on the timeline, with its code, its documents, its travellers and its cost. Click a booking to open its details.',
'help.ctx.trip-bookings.bullet.1':
'Manual Booking at the right end of the bar opens New Reservation. It creates six kinds of bookings: Accommodation, Restaurant, Event, Tour, Parking and Other. Flights, trains and other transport are on the Transports tab and never appear here.',
'Manual Booking at the right end of the bar opens New Reservation. It creates six kinds of bookings: Accommodation, Restaurant, Event, Guided tour, Parking and Other. Flights, trains and other transport are on the Transports tab and never appear here.',
'help.ctx.trip-bookings.bullet.2':
'The download icon before Manual Booking, Import booking confirmations, uploads confirmations so TREK can read them automatically: EML, PDF, PKPass, HTML or TXT, five files of 10 MB at most, and photos when the AI model reads pictures. The icon is only there when the server can read them.',
'help.ctx.trip-bookings.bullet.3':
@@ -3684,7 +3684,7 @@ const help: TranslationStrings = {
'help.guide.create-booking.step.1':
'Click Manual Booking at the right end of the bar. New Reservation opens, with the title and two pills in a header at the top.',
'help.guide.create-booking.step.2':
'Click the type label in the top band, which reads Other on a new booking, and pick the Booking Type. This tab creates six types: Accommodation, Restaurant, Event, Tour, Parking and Other. The form changes with your choice: only Accommodation uses a range of days instead of dates.',
'Click the type label in the top band, which reads Other on a new booking, and pick the Booking Type. This tab creates six types: Accommodation, Restaurant, Event, Guided tour, Parking and Other. The form changes with your choice: only Accommodation uses a range of days instead of dates.',
'help.guide.create-booking.step.3':
'Type the title into the band at the top, the field above the pills. It is the only required field, and Add stays greyed out until you enter something.',
'help.guide.create-booking.step.4':
+1 -1
View File
@@ -62,7 +62,7 @@ const reservations: TranslationStrings = {
'reservations.type.car': 'Car',
'reservations.type.cruise': 'Cruise',
'reservations.type.event': 'Event',
'reservations.type.tour': 'Tour',
'reservations.type.tour': 'Guided tour',
'reservations.type.parking': 'Parking',
'reservations.type.other': 'Other',
'reservations.type.bus': 'Bus',

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