From d1e3d8a75a3481587fc07de7f54617d7a311b5ff Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:00:03 +0200 Subject: [PATCH 1/4] feat(places): offer Waze and Apple Maps beside Google Maps (#1885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Offer Waze and Apple Maps beside Google Maps A place could only be opened in Google Maps, and the request in #919 was for the apps people actually drive with. Google keeps the precise link it always had: ftid, then place id, then the details URL, and coordinates only as a last resort, so it still lands on the entry inside a mall rather than on the roof. Waze and Apple Maps take coordinates and nothing else, which is why they are absent for a place with no position, and why they open with navigation armed instead of on a place view. No setting. Every app that can resolve the place is offered, except Apple Maps, which is dead weight on Android and Windows and therefore only appears on Apple platforms. iPadOS reports itself as a Mac; both sides of that ambiguity have the app, so the check does not need to tell them apart. The picker is a new component rather than the context menu, which has no notion of a submenu. It is portalled because both call sites sit in panels that clip their overflow, and it is measured before it is painted: near the bottom of the window it flips above the trigger instead of running off the screen, and it is pulled back inside when it would overflow to the right. With a single app left to offer there is no menu at all, the button opens it directly, exactly as it behaved before. The three surfaces take it the way each has room for. The inspector button collects them behind one entry, the day-plan context menu lists them flat because a menu can grow, and the phone keeps its single circle and opens the picker as a sheet. * Move OpenStreetMap into the navigation menu, and name the destination OSM was still a button of its own beside the one that now collects the map apps, which is the arrangement the menu exists to end. It belongs with the others. The bigger correction is what Waze and Apple Maps are handed. Both document a query parameter next to the position, and the pair is what makes a destination legible: coordinates say which place is meant, the name is what the driver reads instead of a number. Neither gets a name without a position, because there are a lot of places called "Bahnhof". Google is unchanged; it can already do better than a name through its place id. * Stop drawing the section count as a pill inside a pill SectionHeader wraps its count in a CountPill, and all three callers passed one in already, so every section header carried two stacked pills: a lighter rounded background peeking out from behind the real one. Pass the number and let the header do the wrapping. * Give the traveler filter a label and a way back The avatars floated between the toolbar and the first section with nothing to say what they were, and once a filter was on the only way out was remembering which face had been tapped. It is a labelled bar now, closed off by the same divider the rows below use, with an explicit reset that appears while a filter is active. Both texts already exist in all 23 languages. * Line the traveler filter up with the section titles It carried the sheet's usual 18px of horizontal padding while SectionHeader right below it starts at 2, so the two labels sat on different left edges. * Filter the place list by stars instead of sorting by them Sorting put the best-rated place first and left every other one on the list, which does not help when the question is "what does the group actually want to do". It is a floor now, the same one the collections bar uses: All, or 5+ down to 1+, with unrated places dropping out once a floor is set. The button carries the active floor so the list is never quietly shortened. The row it sits in used to render only when the trip had categories, which hid the star control on a fresh trip. That is exactly where someone collects places to rate, so the row is always there now and only the category dropdown depends on there being categories. places.sortByRating is replaced by places.filterByRating in all 23 languages, since the old label described a behaviour that no longer exists. * Keep the collections map when a filter matches nothing Clicking a label with no places behind it made the map disappear and the page reflow to full width. The layout hung off the FILTERED set: no visible places meant nothing mappable meant no split. A filter should empty the map, not tear it out, so the layout now asks whether the list has anything mappable at all, while the markers keep following the filter. * Give the label filters a tray of their own Two changes to the filter row. Select and manage-labels drop their captions and keep icon, title and aria-label, which puts them on the same footing as the add-place button that was already icon-only. The labels themselves were pill-shaped buttons with their own borders sitting loose among square-cornered dropdowns, so a list with a handful of them read as a pile rather than as one control. They share the filter buttons' radius and type now, and sit in a recessed strip led by a tag glyph: the chips carry no border of their own, because a frame inside a frame was half the noise. Manage stays quiet at the end of the strip until hovered. * Put the collections controls on one line The filter row sat at about 31px while the buttons floating over the map are 38, and the icon-only add button was the odd one out even within its own row. Scrolled to the top, where the row and the map's top bar are side by side, that reads as three different heights. One --col-ctl-h for both, fixed rather than derived from padding so a button with an icon and one with a word cannot drift apart, and the row starts at the same offset the map's own bar does. Phones raise the variable instead of patching individual min-heights, which keeps the square buttons square. * Offer Apple Maps on desktops that are not Apple Apple Maps has had a web version since 2024, so the link resolves to a usable map on Windows and Linux rather than to nothing. Hiding it there was a guess about a limitation that no longer exists. Android keeps the exemption: the web version works there too, but nobody navigating from an Android phone reaches for Apple Maps, and the row of choices is short on purpose. * Move the label filter into the map's top bar The filter row was running out of room while the map's own bar had space to spare, so the labels ride up there next to the collapse button, in the same glass as the controls beside them and scrolling sideways rather than wrapping. They fall back into the filter row whenever no map is on screen — a narrow window in list view, or a list with nothing mappable in it — so they can never end up somewhere the user cannot reach. Both places render the same component now instead of two copies drifting apart. * Stop the active select button drawing a dark icon on a dark fill Two rules with the same specificity claimed the same class. On a dropdown `.open` means the panel is down, so the label stays dark; on the select button it meant the mode is on, which fills it with the accent and needs the light text. The dropdown rule sits later in the file, so it won, and the icon disappeared into the fill the moment the button was clicked. The state is `.on` now, which is what the other toggles on this page (col-map-btn, col-iconbtn) already call it. * Open the place in Google Maps, not the coordinates Reported in #1278 and still reproducible: without a Google identifier the link carried nothing but latitude and longitude, so Maps dropped an unlabelled pin instead of opening the entry. The name and the address were both on hand and both thrown away. Google documents query=PLACE_NAME,ADDRESS for exactly this case, so that is what the fallback sends now. A name with no address still falls through to coordinates, because "Central Station" on its own would confidently open the wrong city, and a pin in the right spot beats a link to the wrong place. The precise paths are untouched: an ftid or a place id still wins, and they are what a place searched through Google carries. This only changes what happens for the ones that carry neither, which is every place added from OpenStreetMap, by hand, or before those fields existed. --- .../Collections/CollectionFilterBar.test.tsx | 12 ++ .../Collections/CollectionFilterBar.tsx | 47 +++---- .../Collections/CollectionLabelFilter.tsx | 72 ++++++++++ .../Collections/CollectionMapPanel.tsx | 26 +++- .../src/components/Planner/DayPlanSidebar.tsx | 9 +- .../Planner/PlaceInspector.test.tsx | 35 +++-- .../src/components/Planner/PlaceInspector.tsx | 47 +++++-- .../components/Planner/PlacesSidebar.test.tsx | 22 +++ .../Planner/PlacesSidebarHeader.tsx | 75 +++++++--- .../Planner/placeGoogleMaps.test.ts | 16 ++- .../src/components/Planner/placeGoogleMaps.ts | 15 +- .../Planner/placeNavigation.test.ts | 101 ++++++++++++++ .../src/components/Planner/placeNavigation.ts | 95 +++++++++++++ .../Planner/usePlacesSidebar.test.tsx | 20 +-- .../components/Planner/usePlacesSidebar.ts | 18 +-- .../components/shared/NavigationMenu.test.tsx | 89 ++++++++++++ .../src/components/shared/NavigationMenu.tsx | 131 ++++++++++++++++++ .../screens/trip/sheets/MPlaceSheet.tsx | 35 ++++- .../screens/trip/sheets/MTripSheetUi.tsx | 7 +- .../mobile/screens/trip/tabs/MBookingsTab.tsx | 13 +- .../mobile/screens/trip/tabs/MCollabPolls.tsx | 4 +- .../screens/trip/tabs/MTransportsTab.tsx | 13 +- .../mobile/screens/trip/tabs/tabChrome.tsx | 72 +++++++--- client/src/pages/CollectionsPage.test.tsx | 20 ++- client/src/pages/CollectionsPage.tsx | 15 +- .../src/pages/CollectionsPage.wiring.test.tsx | 33 +++-- .../src/pages/collections/useCollections.ts | 7 +- client/src/styles/collections.css | 64 +++++++-- .../unit/mobile/trip/MPlaceSheet.test.tsx | 11 +- .../tests/unit/mobile/trip/tabChrome.test.tsx | 50 +++++++ shared/src/i18n/ar/inspector.ts | 2 + shared/src/i18n/ar/places.ts | 2 +- shared/src/i18n/br/inspector.ts | 2 + shared/src/i18n/br/places.ts | 2 +- shared/src/i18n/ca/inspector.ts | 2 + shared/src/i18n/ca/places.ts | 2 +- shared/src/i18n/cs/inspector.ts | 2 + shared/src/i18n/cs/places.ts | 2 +- shared/src/i18n/de/inspector.ts | 2 + shared/src/i18n/de/places.ts | 2 +- shared/src/i18n/en/inspector.ts | 2 + shared/src/i18n/en/places.ts | 2 +- shared/src/i18n/es/inspector.ts | 2 + shared/src/i18n/es/places.ts | 2 +- shared/src/i18n/fr/inspector.ts | 2 + shared/src/i18n/fr/places.ts | 2 +- shared/src/i18n/gr/inspector.ts | 2 + shared/src/i18n/gr/places.ts | 2 +- shared/src/i18n/hu/inspector.ts | 2 + shared/src/i18n/hu/places.ts | 2 +- shared/src/i18n/id/inspector.ts | 2 + shared/src/i18n/id/places.ts | 2 +- shared/src/i18n/it/inspector.ts | 2 + shared/src/i18n/it/places.ts | 2 +- shared/src/i18n/ja/inspector.ts | 2 + shared/src/i18n/ja/places.ts | 2 +- shared/src/i18n/ko/inspector.ts | 2 + shared/src/i18n/ko/places.ts | 2 +- shared/src/i18n/nl/inspector.ts | 2 + shared/src/i18n/nl/places.ts | 2 +- shared/src/i18n/pl/inspector.ts | 2 + shared/src/i18n/pl/places.ts | 2 +- shared/src/i18n/ru/inspector.ts | 2 + shared/src/i18n/ru/places.ts | 2 +- shared/src/i18n/sv/inspector.ts | 2 + shared/src/i18n/sv/places.ts | 2 +- shared/src/i18n/tr/inspector.ts | 2 + shared/src/i18n/tr/places.ts | 2 +- shared/src/i18n/uk/inspector.ts | 2 + shared/src/i18n/uk/places.ts | 2 +- shared/src/i18n/vi/inspector.ts | 2 + shared/src/i18n/vi/places.ts | 2 +- shared/src/i18n/zh-TW/inspector.ts | 2 + shared/src/i18n/zh-TW/places.ts | 2 +- shared/src/i18n/zh/inspector.ts | 2 + shared/src/i18n/zh/places.ts | 2 +- 76 files changed, 1087 insertions(+), 179 deletions(-) create mode 100644 client/src/components/Collections/CollectionLabelFilter.tsx create mode 100644 client/src/components/Planner/placeNavigation.test.ts create mode 100644 client/src/components/Planner/placeNavigation.ts create mode 100644 client/src/components/shared/NavigationMenu.test.tsx create mode 100644 client/src/components/shared/NavigationMenu.tsx diff --git a/client/src/components/Collections/CollectionFilterBar.test.tsx b/client/src/components/Collections/CollectionFilterBar.test.tsx index da13a5a21..bc63e3c55 100644 --- a/client/src/components/Collections/CollectionFilterBar.test.tsx +++ b/client/src/components/Collections/CollectionFilterBar.test.tsx @@ -131,4 +131,16 @@ describe('CollectionFilterBar', () => { render(); expect(screen.queryByRole('button', { name: 'Select' })).not.toBeInTheDocument(); }); + + it('FE-COMP-COLFILTERBAR-009: the active Select button marks itself with `on`, not `open`', () => { + // `.open` belongs to the dropdowns, where it means the panel is down and the + // label stays dark. Sharing that class made the active button paint a dark + // icon on its dark accent fill, because the dropdown rule sits later in the + // stylesheet and both have the same specificity. + render(); + const selectBtn = screen.getByRole('button', { name: 'Select' }); + expect(selectBtn).toHaveClass('on'); + expect(selectBtn).not.toHaveClass('open'); + expect(selectBtn).toHaveAttribute('aria-pressed', 'true'); + }); }); diff --git a/client/src/components/Collections/CollectionFilterBar.tsx b/client/src/components/Collections/CollectionFilterBar.tsx index 2dec032b7..21b262ff4 100644 --- a/client/src/components/Collections/CollectionFilterBar.tsx +++ b/client/src/components/Collections/CollectionFilterBar.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useRef, useState } from 'react' -import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react' +import { ChevronDown, Check, Layers, Tag, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react' import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore' import type { TranslationFn } from '../../types' import { getCategoryIcon } from '../shared/categoryIcons' import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel' import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel' +import CollectionLabelFilter from './CollectionLabelFilter' interface Opt { key: string | number @@ -148,36 +149,26 @@ export default function CollectionFilterBar({ onRatingFilter(k as number | 'all')} lead={} /> onSortMode(k as CollectionSortMode)} lead={} /> {showSelect && ( - )} {showLabels && (labelOptions.length > 0 || canManageLabels) && ( -
- {labelOptions.map(l => { - const on = labelFilter.includes(l.id) - return ( - - ) - })} - {canManageLabels && ( - - )} -
+ )} ) diff --git a/client/src/components/Collections/CollectionLabelFilter.tsx b/client/src/components/Collections/CollectionLabelFilter.tsx new file mode 100644 index 000000000..f124b078d --- /dev/null +++ b/client/src/components/Collections/CollectionLabelFilter.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { Plus, Settings2, Tags } from 'lucide-react' +import type { LabelOption } from '../../pages/collections/collectionsModel' +import type { TranslationFn } from '../../types' + +export type { LabelOption } + +interface CollectionLabelFilterProps { + labelOptions: LabelOption[] + labelFilter: number[] + onLabelFilter: (ids: number[]) => void + canManageLabels?: boolean + onManageLabels?: () => void + /** + * 'bar' is the filter row above the list, 'map' the strip floating over the + * map, which needs the same glass treatment as the buttons beside it. + */ + variant?: 'bar' | 'map' + t: TranslationFn +} + +/** + * The label filter, in one place because it has two homes. + * + * Labels are an axis of their own next to status, category and rating, so they + * are grouped into a tray instead of joining the row as more loose buttons. On + * a desktop it rides in the map's top bar, where there is room to spare; the + * filter row keeps it whenever no map is on screen, so it never becomes + * unreachable. + */ +export default function CollectionLabelFilter({ + labelOptions, labelFilter, onLabelFilter, canManageLabels = false, onManageLabels, + variant = 'bar', t, +}: CollectionLabelFilterProps): React.ReactElement { + return ( +
+
+ ) +} diff --git a/client/src/components/Collections/CollectionMapPanel.tsx b/client/src/components/Collections/CollectionMapPanel.tsx index 718f09de5..4658da62a 100644 --- a/client/src/components/Collections/CollectionMapPanel.tsx +++ b/client/src/components/Collections/CollectionMapPanel.tsx @@ -3,6 +3,7 @@ import { PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react' import type { CollectionPlace } from '@trek/shared' import type { TranslationFn } from '../../types' import CollectionMap from './CollectionMap' +import CollectionLabelFilter, { type LabelOption } from './CollectionLabelFilter' interface CollectionMapPanelProps { places: CollectionPlace[] @@ -17,6 +18,16 @@ interface CollectionMapPanelProps { onToggleView: () => void search: string onSearch: (v: string) => void + /** + * The label filter rides along in the map's top bar, where there is room for + * it. The filter row keeps it whenever no map is on screen, so it can never + * become unreachable. + */ + labelOptions?: LabelOption[] + labelFilter?: number[] + onLabelFilter?: (ids: number[]) => void + canManageLabels?: boolean + onManageLabels?: () => void t: TranslationFn } @@ -27,8 +38,10 @@ interface CollectionMapPanelProps { */ export default function CollectionMapPanel({ places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView, - search, onSearch, t, + search, onSearch, labelOptions = [], labelFilter = [], onLabelFilter, canManageLabels = false, + onManageLabels, t, }: CollectionMapPanelProps): React.ReactElement { + const showLabels = onLabelFilter != null && (labelOptions.length > 0 || canManageLabels) return (
{view === 'map' ? : } + {showLabels && ( + + )}
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 6a1a9d04d..06b6d137b 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -43,7 +43,7 @@ import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDet import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay' import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' -import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' +import { getNavigationTargets, openNavigationTarget } from './placeNavigation' interface DayPlanSidebarProps { tripId: number @@ -1701,12 +1701,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }} onContextMenu={e => { - const googleMapsUrl = getGoogleMapsUrlForPlace(place) + // Flat entries, one per app: the menu has room to + // grow and a submenu would need a component that + // does not exist here. + const navTargets = getNavigationTargets(place) ctxMenu.open(e, [ canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) }, canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) }, place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, - googleMapsUrl && { label: t('inspector.google'), icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') }, + ...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })), collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) }, { divider: true }, canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index 8790ca3e8..0dcf4cc0a 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render'; +import { render, screen, waitFor, fireEvent, act, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { buildUser, buildTrip, buildPlace, buildCategory, buildReservation } from '../../../tests/helpers/factories'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; @@ -632,13 +632,18 @@ describe('PlaceInspector', () => { // ── Google Maps URL action ───────────────────────────────────────────────── - it('FE-PLANNER-INSPECTOR-043: Google Maps lat/lng button visible when no google_maps_url', () => { + it('FE-PLANNER-INSPECTOR-043: the navigation button offers every app the place can be opened in', async () => { + const user = userEvent.setup(); render(); - // place has lat/lng so Google Maps button should appear with Navigation icon - const allButtons = screen.getAllByRole('button'); - // Find button containing "Google Maps" text - const mapsBtn = allButtons.find(btn => btn.textContent?.includes('Google Maps')); - expect(mapsBtn).toBeTruthy(); + // A place with coordinates reaches Google Maps and Waze (Apple Maps only on + // Apple platforms), so the button collects them behind one entry. + const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!; + expect(navBtn).toBeTruthy(); + + await user.click(navBtn); + const menu = await screen.findByRole('menu'); + expect(within(menu).getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument(); + expect(within(menu).getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument(); }); it('FE-PLANNER-INSPECTOR-043b: Google Maps action uses google_ftid over coordinates', async () => { @@ -651,9 +656,10 @@ describe('PlaceInspector', () => { lng: -80.5542617, google_ftid: '0x882bf179e806d471:0x8591dde29c821a93', })} />); - const mapsBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Google Maps'))!; - await user.click(mapsBtn); - expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank'); + const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!; + await user.click(navBtn); + await user.click(await screen.findByRole('menuitem', { name: 'Google Maps' })); + expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank', 'noopener,noreferrer'); openSpy.mockRestore(); }); @@ -934,12 +940,17 @@ describe('PlaceInspector', () => { // ── Footer actions ─────────────────────────────────────────────────────────── - it('FE-PLANNER-INSPECTOR-069: the OpenStreetMap and website buttons open their targets in a new tab', () => { + it('FE-PLANNER-INSPECTOR-069: OpenStreetMap sits in the navigation menu, the website keeps its own button', async () => { + const user = userEvent.setup(); const open = vi.fn(); vi.stubGlobal('open', open); const p = buildPlace({ id: 705, name: 'Linked Place', osm_id: 'node/12345', website: 'https://example.org' }); render(); - fireEvent.click(screen.getByText('OpenStreetMap').closest('button')!); + + // OSM moved in with the other map apps rather than standing beside them. + await user.click(screen.getAllByRole('button').find(b => b.textContent?.includes('Navigation'))!); + await user.click(await screen.findByRole('menuitem', { name: 'OpenStreetMap' })); + fireEvent.click(screen.getByText('Open Website').closest('button')!); expect(open).toHaveBeenCalledTimes(2); expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank']); diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index d7ad08ffe..8d53e9567 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -27,8 +27,8 @@ import type { CollectionStatus } from '@trek/shared' import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters' import { useTripStore } from '../../store/tripStore' import { formatDistance, formatElevation } from '../../utils/units' -import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' -import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap' +import { getNavigationTargets, openNavigationTarget } from './placeNavigation' +import { NavigationMenu } from '../shared/NavigationMenu' import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState' import { convertHoursLine } from './placeHoursFormat' @@ -178,6 +178,8 @@ export default function PlaceInspector({ // Extra native rows contributed by placeDetailProvider plugins (#1429). Fail-safe: // any provider error/timeout is dropped server-side, so this only ever adds rows. const [providerDetails, setProviderDetails] = useState }>>([]) + const [navOpen, setNavOpen] = useState(false) + const navBtnRef = useRef(null) const placeIdForDetails = mode === 'trip' ? place?.id : undefined useEffect(() => { if (placeIdForDetails == null) { setProviderDetails([]); return } @@ -316,11 +318,10 @@ export default function PlaceInspector({ googleDetails?.open_now, ) // Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google. - const googleMapsUrl = getGoogleMapsUrlForPlace( + const navigationTargets = getNavigationTargets( place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null, googleDetails?.google_maps_url, ) - const openStreetMapUrl = getOpenStreetMapUrlForPlace(place) const selectedDay = days?.find(d => d.id === selectedDayId) const weekdayIndex = getWeekdayIndex(selectedDay?.date, placeTimeZone) @@ -484,13 +485,32 @@ export default function PlaceInspector({ icon={savedInCollection ? : } label={{savedInCollection ? t('inspector.savedToCollection') : t('inspector.saveToCollection')}} /> )} - {googleMapsUrl && ( - window.open(googleMapsUrl, '_blank')} variant="ghost" icon={} - label={{t('inspector.google')}} /> - )} - {openStreetMapUrl && ( - window.open(openStreetMapUrl, '_blank')} variant="ghost" icon={} - label={{t('inspector.openStreetMap')}} /> + {navigationTargets.length > 0 && ( + <> + {/* One target left (a place without coordinates) opens straight + away, exactly as this button always did. */} + { + if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0]) + else setNavOpen(o => !o) + }} + variant="ghost" + icon={} + label={ + + {navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')} + + } + /> + {navOpen && ( + setNavOpen(false)} + /> + )} + )} {(place.website || googleDetails?.website) && ( window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={} @@ -535,9 +555,11 @@ interface ActionButtonProps { variant: 'primary' | 'ghost' | 'danger' icon: React.ReactNode label: React.ReactNode + /** For callers that anchor a popup to the button. React 19 passes it through. */ + ref?: React.Ref } -export function ActionButton({ onClick, variant, icon, label }: ActionButtonProps) { +export function ActionButton({ onClick, variant, icon, label, ref }: ActionButtonProps) { const base = { primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' }, ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' }, @@ -546,6 +568,7 @@ export function ActionButton({ onClick, variant, icon, label }: ActionButtonProp const s = base[variant] || base.ghost return ( - {/* Star sort (#1435): order the list by average rating, best first. */} - + } + {/* Minimum-stars filter (#1435), same floors as the collections bar. */} + + {starDropOpen && ( +
+ {(['all', 5, 4, 3, 2, 1] as const).map(opt => { + const active = ratingFilter === opt + return ( + + ) + })} +
+ )} {canEditPlaces && ( + ))} +
, + document.body, + ) +} diff --git a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx index 3d8cb10e6..544a70b3b 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx @@ -18,7 +18,8 @@ import TrackColorPicker from '../../../../components/shared/TrackColorPicker' import { resolveTrackColor, inheritedTrackColor } from '../../../../components/Map/trackColors' import { avatarSrc } from '../../../../utils/avatarSrc' import { openFile } from '../../../../utils/fileDownload' -import { getGoogleMapsUrlForPlace } from '../../../../components/Planner/placeGoogleMaps' +import { getNavigationTargets, openNavigationTarget } from '../../../../components/Planner/placeNavigation' +import { NavigationMenu } from '../../../../components/shared/NavigationMenu' import type { Assignment, Day, TripMember } from '../../../../types' import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi' @@ -43,6 +44,8 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { const [dayPickerOpen, setDayPickerOpen] = useState(false) const [participantPickerOpen, setParticipantPickerOpen] = useState(false) const [uploading, setUploading] = useState(false) + const [navOpen, setNavOpen] = useState(false) + const navBtnRef = useRef(null) const [imgBusy, setImgBusy] = useState(false) const [colorPickerOpen, setColorPickerOpen] = useState(false) const fileInputRef = useRef(null) @@ -199,9 +202,13 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { planner.setFitKey(k => k + 1) } + // One app installed-and-offered means no picker: the tap opens it, which is + // what this button has always done. The sheet only appears when there is an + // actual choice to make. + const navTargets = getNavigationTargets(place) const openDirections = () => { - const url = getGoogleMapsUrlForPlace(place) - if (url) window.open(url, '_blank', 'noopener,noreferrer') + if (navTargets.length === 1) openNavigationTarget(navTargets[0]) + else if (navTargets.length > 1) setNavOpen(true) } return ( @@ -496,10 +503,24 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { )} - {place.lat != null && place.lng != null && ( - - - + {navTargets.length > 0 && ( + <> + + + + {navOpen && ( + setNavOpen(false)} + title={t('inspector.openWith')} + /> + )} + )} {place.lat != null && place.lng != null && ( diff --git a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx index 156587a5e..6ce637da2 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, Ref } from 'react' import { X } from 'lucide-react' import MIconBtn from '../../../components/MIconBtn' import { formatTime } from '../../../../utils/formatters' @@ -88,12 +88,15 @@ interface ActionCircleProps { danger?: boolean className?: string children: ReactNode + /** For callers that anchor a popup to the button. React 19 passes it through. */ + ref?: Ref } /** 38px round action button of the inspector footer rows. */ -export function ActionCircle({ onClick, label, primary = false, danger = false, className = '', children }: ActionCircleProps) { +export function ActionCircle({ onClick, label, primary = false, danger = false, className = '', children, ref }: ActionCircleProps) { return ( - ) - })} +
+ + {label} + +
+ {members.map(m => { + const on = active.has(m.id) + return ( + + ) + })} +
+ {filtering && allLabel && onClear && ( + + )}
) } diff --git a/client/src/pages/CollectionsPage.test.tsx b/client/src/pages/CollectionsPage.test.tsx index c67620953..1e7634f63 100644 --- a/client/src/pages/CollectionsPage.test.tsx +++ b/client/src/pages/CollectionsPage.test.tsx @@ -69,6 +69,7 @@ function makeHook(overrides: Record = {}) { places: [{ id: 10 }], visiblePlaces: [{ id: 10 }], mappable: [{ id: 10 }], + hasMappable: true, members: [], incomingInvites: [], counts: { all: 1, idea: 0, want: 1, visited: 0 }, @@ -124,8 +125,25 @@ describe('CollectionsPage — Add place button reachability (#1485)', () => { }) it('shows the Add button on an empty collection', () => { - mockUseCollections.mockReturnValue(makeHook({ places: [], visiblePlaces: [], mappable: [] })) + mockUseCollections.mockReturnValue(makeHook({ places: [], visiblePlaces: [], mappable: [], hasMappable: false })) render() expect(screen.getAllByRole('button', { name: 'collections.addPlace' })).toHaveLength(1) }) }) + +describe('CollectionsPage — the map survives a filter that matches nothing', () => { + it('keeps the map when a label filter empties the list', () => { + // Clicking a label with no places behind it left visiblePlaces empty, and + // the layout hung off exactly that: the map was torn out and the page + // reflowed to full width. A filter should empty the map, not remove it. + mockUseCollections.mockReturnValue(makeHook({ visiblePlaces: [], mappable: [], hasMappable: true })) + render() + expect(screen.getByTestId('map')).toBeInTheDocument() + }) + + it('still hides the map for a list with nothing mappable in it at all', () => { + mockUseCollections.mockReturnValue(makeHook({ mappable: [], hasMappable: false })) + render() + expect(screen.queryByTestId('map')).toBeNull() + }) +}) diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index ccbe9c8a9..5ac3015f9 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -59,8 +59,8 @@ function CollectionsPageDesktop(): React.ReactElement { // + detail come into view alongside the map. const onMapSelect = (id: number) => { openPlace(id); if (c.view === 'map') c.setView('list') } - const desktopSplit = c.isWide && mappable.length > 0 - const mapShown = mappable.length > 0 && (c.view === 'map' || c.isWide) + const desktopSplit = c.isWide && c.hasMappable + const mapShown = c.hasMappable && (c.view === 'map' || c.isWide) const mapOverlay = c.isWide && mapShown // the map carries the toggle + search const canAddPlace = typeof c.activeId === 'number' && c.canEdit // a real list you can edit @@ -79,6 +79,11 @@ function CollectionsPageDesktop(): React.ReactElement { ) const mapPanel = (overlay: boolean) => ( c.setShowLabelManager(true)} places={mappable} selectedPlaceId={c.selectedPlaceId} onSelect={onMapSelect} @@ -109,7 +114,7 @@ function CollectionsPageDesktop(): React.ReactElement { onSortMode={c.setSortMode} canAddPlace={canAddPlace} onAddPlace={() => c.setShowAddPlace(true)} - showLabels={isRealList} + showLabels={isRealList && !mapShown} labelOptions={c.labelOptions} labelFilter={c.labelFilter} onLabelFilter={c.setLabelFilter} @@ -151,7 +156,7 @@ function CollectionsPageDesktop(): React.ReactElement {
{mapPanel(true)}
) - } else if (c.view === 'map' && mappable.length > 0) { + } else if (c.view === 'map' && c.hasMappable) { body =
{mapPanel(false)}
} else { body = listColumn @@ -212,7 +217,7 @@ function CollectionsPageDesktop(): React.ReactElement { - {!c.isWide && mappable.length > 0 && ( + {!c.isWide && c.hasMappable && (
} @@ -206,6 +208,7 @@ function makeHook(overrides: Hook = {}): Hook { places: [{ id: 10 }], visiblePlaces: [{ id: 10 }], mappable: [{ id: 10 }], + hasMappable: true, members: [], incomingInvites: [], counts: { all: 1, idea: 0, want: 1, visited: 0 }, @@ -340,7 +343,7 @@ describe('CollectionsPage — shell', () => { }) it('FE-PAGE-COLLPAGE-007: shows a spinner while the first places load', () => { - renderPage({ places: [], visiblePlaces: [], mappable: [], placesLoading: true }) + renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false, placesLoading: true }) expect(document.querySelector('.col-spinner')).not.toBeNull() expect(screen.queryByTestId('list')).toBeNull() }) @@ -348,7 +351,7 @@ describe('CollectionsPage — shell', () => { describe('CollectionsPage — body branches', () => { it('FE-PAGE-COLLPAGE-008: an empty list shows the empty state plus the add CTA', () => { - const hook = renderPage({ places: [], visiblePlaces: [], mappable: [] }) + const hook = renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false }) expect(screen.getByTestId('empty-collections')).toHaveTextContent('collections.empty.title') fireEvent.click(screen.getByRole('button', { name: /collections.addPlace/ })) @@ -356,7 +359,7 @@ describe('CollectionsPage — body branches', () => { }) it('FE-PAGE-COLLPAGE-009: a viewer on an empty list gets no add CTA', () => { - renderPage({ places: [], visiblePlaces: [], mappable: [], canEdit: false }) + renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false, canEdit: false }) expect(screen.getByTestId('empty-collections')).toBeInTheDocument() expect(screen.queryByRole('button', { name: /collections.addPlace/ })).toBeNull() }) @@ -384,13 +387,13 @@ describe('CollectionsPage — body branches', () => { }) it('FE-PAGE-COLLPAGE-013: places without coordinates keep the list column even on a wide layout', () => { - renderPage({ mappable: [] }) + renderPage({ mappable: [], hasMappable: false }) expect(screen.getByTestId('list')).toBeInTheDocument() expect(screen.queryByTestId('map-overlay')).toBeNull() }) it('FE-PAGE-COLLPAGE-014: a filter that matches nothing keeps the filter bar and shows the no-match state', () => { - renderPage({ visiblePlaces: [], mappable: [] }) + renderPage({ visiblePlaces: [], mappable: [], hasMappable: false }) expect(screen.getByTestId('filterbar')).toBeInTheDocument() expect(screen.getByTestId('empty-search')).toHaveTextContent('collections.empty.noMatchTitle') expect(screen.queryByTestId('list')).toBeNull() @@ -555,20 +558,32 @@ describe('CollectionsPage — child wiring', () => { expect(hook.setSelectedPlaceId).not.toHaveBeenCalled() }) - it('FE-PAGE-COLLPAGE-030: the filter bar drives add-place, label management and select mode', () => { + it('FE-PAGE-COLLPAGE-030: the filter bar drives add-place and select mode, the map takes the labels', () => { const hook = renderPage() - expect(screen.getByTestId('filter-flags')).toHaveTextContent('true|true|true') + // Labels ride in the map's top bar while a map is on screen, so the filter + // row is told not to draw them a second time. + expect(screen.getByTestId('filter-flags')).toHaveTextContent('false|true|true') + expect(screen.getByTestId('map-has-labels')).toHaveTextContent('true') fireEvent.click(screen.getByText('filter-add')) expect(hook.setShowAddPlace).toHaveBeenCalledWith(true) - fireEvent.click(screen.getByText('filter-labels')) + fireEvent.click(screen.getByText('map-labels')) expect(hook.setShowLabelManager).toHaveBeenCalledWith(true) fireEvent.click(screen.getByText('filter-select')) expect(hook.setSelectMode).toHaveBeenCalledWith(true) }) + it('FE-PAGE-COLLPAGE-030b: without a map the filter row keeps the labels, so they stay reachable', () => { + const hook = renderPage({ mappable: [], hasMappable: false }) + expect(screen.getByTestId('filter-flags')).toHaveTextContent('true|true|true') + expect(screen.queryByTestId('map-overlay')).toBeNull() + + fireEvent.click(screen.getByText('filter-labels')) + expect(hook.setShowLabelManager).toHaveBeenCalledWith(true) + }) + it('FE-PAGE-COLLPAGE-031: the All-saved union has no labels and no add button in the filter row', () => { renderPage({ isAllSaved: true, activeId: 'all', activeCollection: null }) expect(screen.getByTestId('filter-flags')).toHaveTextContent('false|false|true') diff --git a/client/src/pages/collections/useCollections.ts b/client/src/pages/collections/useCollections.ts index 86d53760c..0b11dc110 100644 --- a/client/src/pages/collections/useCollections.ts +++ b/client/src/pages/collections/useCollections.ts @@ -177,6 +177,11 @@ export function useCollections() { // Stable reference so the map doesn't tear down + rebuild every marker on each // unrelated re-render (which would swallow marker clicks mid-rebuild). const mappable = useMemo(() => mappablePlaces(visiblePlaces), [visiblePlaces]) + // Whether this list has anything mappable AT ALL, filters aside. The layout + // hangs off this rather than off `mappable`: a label with no places left the + // filtered set empty, which tore the map out and reflowed the page to full + // width. The filter should empty the map, not remove it. + const hasMappable = useMemo(() => mappablePlaces(places).length > 0, [places]) const counts = useMemo(() => statusCounts(places), [places]) // ── Handlers ──────────────────────────────────────────────────────── @@ -385,7 +390,7 @@ export function useCollections() { collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner, myRole, canEdit, canDelete, canShare, shareMemberCount, - activeId, places, visiblePlaces, mappable, members, incomingInvites, counts, + activeId, places, visiblePlaces, mappable, hasMappable, members, incomingInvites, counts, view, statusFilter, categoryFilter, categoryOptions, ratingFilter, sortMode, search, selectedPlaceId, selectMode, selectedIds, labels, labelFilter, labelOptions, loading, placesLoading, diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index dc26384ce..0f98169a6 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -274,9 +274,31 @@ .trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); } .trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); } .trek-dash .col-labelchip-dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; background: var(--label, var(--accent)); } -.trek-dash .col-labelfilter { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; } -.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; } -.trek-dash .col-labelchip:hover { background: var(--surface-2); color: var(--ink); } +/* Labels are their own axis of filtering, so they read as one tray rather than + as more loose buttons: a recessed strip, led by a tag glyph, holding chips + that share the filter buttons' radius and type. The chips carry no border of + their own — a frame inside a frame is what made this look like a pile. */ +.trek-dash .col-labelfilter { + display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; + min-height: var(--col-ctl-h); padding: 3px 6px 3px 9px; border-radius: 11px; + background: color-mix(in oklch, var(--surface-2) 70%, transparent); + border: 1px solid var(--line-2); +} +.trek-dash .col-labelfilter-lead { color: var(--ink-3); flex-shrink: 0; margin-right: 2px; } +/* On the map the tray joins the floating controls, so it takes their glass, + their radius and their shadow instead of the row's flat surface. */ +.trek-dash .col-labelfilter.on-map { + pointer-events: auto; max-width: min(52ch, 100%); flex-wrap: nowrap; overflow-x: auto; + border-radius: 11px; border-color: var(--glass-border); box-shadow: var(--sh-md); + background: color-mix(in oklch, var(--surface) 92%, transparent); + backdrop-filter: blur(8px) saturate(1.2); -webkit-backdrop-filter: blur(8px) saturate(1.2); + scrollbar-width: none; +} +.trek-dash .col-labelfilter.on-map::-webkit-scrollbar { display: none; } +.trek-dash .col-labelfilter.on-map .col-labelchip { flex-shrink: 0; } +.trek-dash .col-labelfilter.on-map .col-labelchip:hover { background: var(--surface-2); } +.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 5px 9px; border-radius: 8px; font-size: 12.5px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: transparent; border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; } +.trek-dash .col-labelchip:hover { background: var(--surface); color: var(--ink); } .trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; } .trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); } .trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); } @@ -284,14 +306,35 @@ .trek-dash .col-detail-labels { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 10px; } /* ----------------- filter bar (status + category dropdowns) ----------------- */ -.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } +/* One control height for the whole page: the filter row and the buttons floating + over the map are read as a single band when the page is scrolled to the top, + so they share a height and a top offset instead of each having their own. */ +.trek-dash { --col-ctl-h: 38px; } +.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; } +/* Beside the map, the row starts where the map's own top bar does. */ +.trek-dash .col-split-list .col-filterbar { margin-top: 12px; } .trek-dash .col-filter { position: relative; } /* Icon-only add-place button: square it off so it matches the dropdowns' height. */ -.trek-dash .col-filter-add { padding: 7px; } +/* Square icon-only buttons: as wide as they are tall, so they sit in the row + without being the one element that is a few pixels off. */ +.trek-dash .col-filter-add, +.trek-dash .col-filter-icon { width: var(--col-ctl-h); padding: 0; justify-content: center; } +/* Manage/add labels sits inside the label tray, so it stays quiet until hovered. */ +.trek-dash .col-filter-addlabel { + display: inline-flex; align-items: center; justify-content: center; + padding: 5px; border-radius: 8px; color: var(--ink-3); + background: transparent; border: 1px solid transparent; + transition: background .12s, color .12s; +} +.trek-dash .col-filter-addlabel:hover { background: var(--surface); color: var(--ink); } .trek-dash .col-filter-select { margin-left: auto; } -.trek-dash .col-filter-select.open { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } -.trek-dash .col-filter-select.open:hover { background: var(--accent); color: var(--accent-text); } -.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; } +/* `.on`, not `.open`: on a dropdown "open" means the panel is down and the text + stays dark, and that rule sits later in this file, so sharing the name left + the active select button drawing a dark icon on its dark accent fill. The + other toggles in this page (col-map-btn, col-iconbtn) already use `.on`. */ +.trek-dash .col-filter-select.on { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } +.trek-dash .col-filter-select.on:hover { background: var(--accent); color: var(--accent-text); } +.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; height: var(--col-ctl-h); padding: 0 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; } .trek-dash .col-filter-btn:hover { background: var(--surface-2); color: var(--ink); } .trek-dash .col-filter-btn.open { border-color: var(--accent); color: var(--ink); } .trek-dash .col-filter-btn .col-filter-chev { color: var(--ink-3); transition: transform .15s; } @@ -461,9 +504,10 @@ .trek-dash .col-search { flex: 1; } .trek-dash .col-mapwrap { height: calc(100dvh - 260px); min-height: 320px; } - /* bigger touch targets on phones (visuals unchanged on desktop) */ + /* bigger touch targets on phones (visuals unchanged on desktop). Raising the + shared height keeps the square icon buttons square. */ + .trek-dash { --col-ctl-h: 40px; } .trek-dash .col-viewseg button { width: 44px; height: 38px; } - .trek-dash .col-filter-btn { min-height: 40px; } .trek-dash .col-filter-opt { min-height: 40px; } .trek-dash .col-selbar-btn { min-height: 40px; } .trek-dash .col-detail-close { width: 40px; height: 40px; } diff --git a/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx index cde8129d1..946244266 100644 --- a/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx @@ -368,9 +368,14 @@ describe('MPlaceSheet', () => { const shell = makeShell({ trTab: 'dateien', view: 'plan' }) const { planner } = renderSheet(makePlanner(), shell) - fireEvent.click(screen.getByRole('button', { name: 'Google Maps' })) + // A place with coordinates reaches more than one map app, so the circle + // opens the picker instead of guessing which one was meant. + fireEvent.click(screen.getByRole('button', { name: 'Navigation' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Google Maps' })) + // Name and address rather than bare coordinates (#1278): the place has both, + // so Google opens the entry instead of an unlabelled pin. expect(window.open).toHaveBeenCalledWith( - 'https://www.google.com/maps/search/?api=1&query=48.2038,16.3616', '_blank', 'noopener,noreferrer', + 'https://www.google.com/maps/search/?api=1&query=Kunsthistorisches%20Museum%2C%20Maria-Theresien-Platz', '_blank', 'noopener,noreferrer', ) fireEvent.click(screen.getByRole('button', { name: 'Open Website' })) @@ -405,7 +410,7 @@ describe('MPlaceSheet', () => { expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Save to Collection' })).not.toBeInTheDocument() // Read-only members still get the navigation actions. - expect(screen.getByRole('button', { name: 'Google Maps' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument() }) it('FE-MOB-PLSH-026: collapses the track colour picker again when the sheet closes', () => { diff --git a/client/tests/unit/mobile/trip/tabChrome.test.tsx b/client/tests/unit/mobile/trip/tabChrome.test.tsx index 8ab8f67a6..bd2c464bf 100644 --- a/client/tests/unit/mobile/trip/tabChrome.test.tsx +++ b/client/tests/unit/mobile/trip/tabChrome.test.tsx @@ -148,4 +148,54 @@ describe('tabChrome', () => { expect(screen.getByTitle('Ada').className).toContain('opacity-100') expect(screen.getByTitle('Bob').className).toContain('opacity-100') }) + + it('FE-MOB-TABCHR-015: TravelerFilterRow names itself, so the avatars are not left unexplained', () => { + render( + , + ) + expect(screen.getByRole('group', { name: 'Travelers' })).toBeInTheDocument() + expect(screen.getByText('Travelers')).toBeInTheDocument() + }) + + it('FE-MOB-TABCHR-016: the way out of a filter only appears once one is set', () => { + const onClear = vi.fn() + const { rerender } = render( + , + ) + expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument() + + rerender( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'All' })) + expect(onClear).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-TABCHR-017: SectionHeader wraps its count exactly once', () => { + // The callers used to pass a into a prop that wraps one itself, + // which put a pill inside a pill and showed two stacked backgrounds. + render() + const pill = screen.getByText('2') + expect(pill.querySelector('span')).toBeNull() + expect(pill.parentElement?.tagName).toBe('BUTTON') + }) }) diff --git a/shared/src/i18n/ar/inspector.ts b/shared/src/i18n/ar/inspector.ts index ed125bd92..413aad04a 100644 --- a/shared/src/i18n/ar/inspector.ts +++ b/shared/src/i18n/ar/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'حجز مؤكد', 'inspector.pendingRes': 'حجز قيد الانتظار', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'التنقل', + 'inspector.openWith': 'الفتح باستخدام', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'فتح الموقع الإلكتروني', 'inspector.saveToCollection': 'حفظ في مجموعة', diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index cd2fe539f..197457b30 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'غير مخطط', 'places.planned': 'مخطط لها', 'places.filterTracks': 'المسارات', - 'places.sortByRating': 'الترتيب حسب التقييم', + 'places.filterByRating': 'تصفية حسب التقييم', 'places.yourRating': 'تقييمك', 'places.notRated': 'لم يُقيَّم بعد', 'places.search': 'ابحث عن أماكن...', diff --git a/shared/src/i18n/br/inspector.ts b/shared/src/i18n/br/inspector.ts index ea27087f6..531528444 100644 --- a/shared/src/i18n/br/inspector.ts +++ b/shared/src/i18n/br/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Reserva confirmada', 'inspector.pendingRes': 'Reserva pendente', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navegação', + 'inspector.openWith': 'Abrir com', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.saveToCollection': 'Salvar na Coleção', 'inspector.savedToCollection': 'Salvo', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index 9a51323f7..c92fd76aa 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Não planejados', 'places.planned': 'Planejados', 'places.filterTracks': 'Trilhas', - 'places.sortByRating': 'Ordenar por avaliação', + 'places.filterByRating': 'Filtrar por avaliação', 'places.yourRating': 'Sua avaliação', 'places.notRated': 'Ainda sem avaliações', 'places.search': 'Buscar lugares...', diff --git a/shared/src/i18n/ca/inspector.ts b/shared/src/i18n/ca/inspector.ts index 9c27e5f96..df1893253 100644 --- a/shared/src/i18n/ca/inspector.ts +++ b/shared/src/i18n/ca/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Reserva confirmada', 'inspector.pendingRes': 'Reserva pendent', 'inspector.google': 'Obre a Google Maps', + 'inspector.navigation': 'Navegació', + 'inspector.openWith': 'Obre amb', 'inspector.website': 'Obre el lloc web', 'inspector.addRes': 'Reserva', 'inspector.editRes': 'Edita la reserva', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index 7056bf151..56d3280f1 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -43,7 +43,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Sense planificar', 'places.planned': 'Planificats', 'places.filterTracks': 'Rutes', - 'places.sortByRating': 'Ordena per valoració', + 'places.filterByRating': 'Filtra per valoració', 'places.yourRating': 'La teva valoració', 'places.notRated': 'Encara sense valoració', 'places.search': 'Cerca llocs...', diff --git a/shared/src/i18n/cs/inspector.ts b/shared/src/i18n/cs/inspector.ts index f06d35e40..0ca346de8 100644 --- a/shared/src/i18n/cs/inspector.ts +++ b/shared/src/i18n/cs/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Potvrzená rezervace', 'inspector.pendingRes': 'Čekající rezervace', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigace', + 'inspector.openWith': 'Otevřít v', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Otevřít webové stránky', 'inspector.saveToCollection': 'Uložit do sbírky', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 5cd2df76d..e8073085d 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Nezařazené', 'places.planned': 'Naplánované', 'places.filterTracks': 'Trasy', - 'places.sortByRating': 'Seřadit podle hodnocení', + 'places.filterByRating': 'Filtrovat podle hodnocení', 'places.yourRating': 'Tvé hodnocení', 'places.notRated': 'Zatím bez hodnocení', 'places.search': 'Hledat místa...', diff --git a/shared/src/i18n/de/inspector.ts b/shared/src/i18n/de/inspector.ts index 3cb723e69..0dcb72c28 100644 --- a/shared/src/i18n/de/inspector.ts +++ b/shared/src/i18n/de/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Bestätigte Reservierung', 'inspector.pendingRes': 'Ausstehende Reservierung', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigation', + 'inspector.openWith': 'Öffnen mit', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Webseite öffnen', 'inspector.saveToCollection': 'In Sammlung speichern', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 90c2173ca..46f655dd8 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Ungeplant', 'places.planned': 'Geplant', 'places.filterTracks': 'Tracks', - 'places.sortByRating': 'Nach Bewertung sortieren', + 'places.filterByRating': 'Nach Bewertung filtern', 'places.yourRating': 'Deine Bewertung', 'places.notRated': 'Noch nicht bewertet', 'places.search': 'Orte suchen...', diff --git a/shared/src/i18n/en/inspector.ts b/shared/src/i18n/en/inspector.ts index a0d456eee..a70884d6a 100644 --- a/shared/src/i18n/en/inspector.ts +++ b/shared/src/i18n/en/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Confirmed Reservation', 'inspector.pendingRes': 'Pending Reservation', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigation', + 'inspector.openWith': 'Open with', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Open Website', 'inspector.saveToCollection': 'Save to Collection', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index 90ea728c0..7795ee8a5 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Unplanned', 'places.planned': 'Planned', 'places.filterTracks': 'Tracks', - 'places.sortByRating': 'Sort by rating', + 'places.filterByRating': 'Filter by rating', 'places.yourRating': 'Your rating', 'places.notRated': 'Not rated yet', 'places.search': 'Search places...', diff --git a/shared/src/i18n/es/inspector.ts b/shared/src/i18n/es/inspector.ts index bd16e2e5e..f8dfaa26d 100644 --- a/shared/src/i18n/es/inspector.ts +++ b/shared/src/i18n/es/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Reserva confirmada', 'inspector.pendingRes': 'Reserva pendiente', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navegación', + 'inspector.openWith': 'Abrir con', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Abrir la web', 'inspector.saveToCollection': 'Guardar en colección', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index 458f7774f..b405f2905 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Sin planificar', 'places.planned': 'Planificados', 'places.filterTracks': 'Rutas', - 'places.sortByRating': 'Ordenar por valoración', + 'places.filterByRating': 'Filtrar por valoración', 'places.yourRating': 'Tu valoración', 'places.notRated': 'Sin valorar todavía', 'places.search': 'Buscar lugares...', diff --git a/shared/src/i18n/fr/inspector.ts b/shared/src/i18n/fr/inspector.ts index 30ef5b160..f30c148df 100644 --- a/shared/src/i18n/fr/inspector.ts +++ b/shared/src/i18n/fr/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Réservation confirmée', 'inspector.pendingRes': 'Réservation en attente', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigation', + 'inspector.openWith': 'Ouvrir avec', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Ouvrir le site web', 'inspector.saveToCollection': 'Enregistrer dans une collection', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index 190dd7a63..1b1c71851 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Non planifiés', 'places.planned': 'Planifiés', 'places.filterTracks': 'Traces', - 'places.sortByRating': 'Trier par note', + 'places.filterByRating': 'Filtrer par note', 'places.yourRating': 'Ta note', 'places.notRated': 'Pas encore noté', 'places.search': 'Rechercher des lieux…', diff --git a/shared/src/i18n/gr/inspector.ts b/shared/src/i18n/gr/inspector.ts index 409ba2dc8..b81a6c617 100644 --- a/shared/src/i18n/gr/inspector.ts +++ b/shared/src/i18n/gr/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Επιβεβαιωμένη Κράτηση', 'inspector.pendingRes': 'Εκκρεμής Κράτηση', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Πλοήγηση', + 'inspector.openWith': 'Άνοιγμα με', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.saveToCollection': 'Αποθήκευση στη Συλλογή', 'inspector.savedToCollection': 'Αποθηκεύτηκε', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index d7cf13cbc..d721161c2 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Μη προγραμματισμένα', 'places.planned': 'Προγραμματισμένα', 'places.filterTracks': 'Ίχνη', - 'places.sortByRating': 'Ταξινόμηση κατά βαθμολογία', + 'places.filterByRating': 'Φιλτράρισμα κατά βαθμολογία', 'places.yourRating': 'Η βαθμολογία σου', 'places.notRated': 'Χωρίς βαθμολογία ακόμη', 'places.search': 'Αναζήτηση μερών...', diff --git a/shared/src/i18n/hu/inspector.ts b/shared/src/i18n/hu/inspector.ts index df5236f2c..6c4004134 100644 --- a/shared/src/i18n/hu/inspector.ts +++ b/shared/src/i18n/hu/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Megerősített foglalás', 'inspector.pendingRes': 'Függőben lévő foglalás', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigáció', + 'inspector.openWith': 'Megnyitás ezzel', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Weboldal megnyitása', 'inspector.saveToCollection': 'Mentés gyűjteménybe', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index b9c736ea5..3cb41240c 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Nem tervezett', 'places.planned': 'Tervezett', 'places.filterTracks': 'Nyomvonalak', - 'places.sortByRating': 'Rendezés értékelés szerint', + 'places.filterByRating': 'Szűrés értékelés szerint', 'places.yourRating': 'Az értékelésed', 'places.notRated': 'Még nincs értékelve', 'places.search': 'Helyek keresése...', diff --git a/shared/src/i18n/id/inspector.ts b/shared/src/i18n/id/inspector.ts index 1df45f455..0a88e0146 100644 --- a/shared/src/i18n/id/inspector.ts +++ b/shared/src/i18n/id/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Reservasi Dikonfirmasi', 'inspector.pendingRes': 'Reservasi Menunggu', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigasi', + 'inspector.openWith': 'Buka dengan', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.saveToCollection': 'Simpan ke Koleksi', 'inspector.savedToCollection': 'Tersimpan', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index 59170197b..ae57bb480 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -44,7 +44,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Belum direncanakan', 'places.planned': 'Direncanakan', 'places.filterTracks': 'Trek', - 'places.sortByRating': 'Urutkan berdasarkan rating', + 'places.filterByRating': 'Saring menurut penilaian', 'places.yourRating': 'Rating kamu', 'places.notRated': 'Belum dinilai', 'places.search': 'Cari tempat...', diff --git a/shared/src/i18n/it/inspector.ts b/shared/src/i18n/it/inspector.ts index 5fe161cd9..d8f914b6b 100644 --- a/shared/src/i18n/it/inspector.ts +++ b/shared/src/i18n/it/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Prenotazione confermata', 'inspector.pendingRes': 'Prenotazione in attesa', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigazione', + 'inspector.openWith': 'Apri con', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Apri sito web', 'inspector.saveToCollection': 'Salva nella raccolta', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index b5e537728..812bb1272 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Non pianificati', 'places.planned': 'Pianificati', 'places.filterTracks': 'Tracce', - 'places.sortByRating': 'Ordina per valutazione', + 'places.filterByRating': 'Filtra per valutazione', 'places.yourRating': 'La tua valutazione', 'places.notRated': 'Non ancora valutato', 'places.search': 'Cerca luoghi...', diff --git a/shared/src/i18n/ja/inspector.ts b/shared/src/i18n/ja/inspector.ts index 86f467e7e..13380f861 100644 --- a/shared/src/i18n/ja/inspector.ts +++ b/shared/src/i18n/ja/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': '確定済み予約', 'inspector.pendingRes': '保留中の予約', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'ナビゲーション', + 'inspector.openWith': 'アプリで開く', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Webサイトを開く', 'inspector.saveToCollection': 'コレクションに保存', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index ddc2ad4c2..c358d1d1c 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': '未計画', 'places.planned': '計画済み', 'places.filterTracks': 'トラック', - 'places.sortByRating': '評価順に並べ替え', + 'places.filterByRating': '評価で絞り込む', 'places.yourRating': 'あなたの評価', 'places.notRated': '未評価', 'places.search': '場所を検索…', diff --git a/shared/src/i18n/ko/inspector.ts b/shared/src/i18n/ko/inspector.ts index fb00c9f7f..0a2f3a706 100644 --- a/shared/src/i18n/ko/inspector.ts +++ b/shared/src/i18n/ko/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': '확정된 예약', 'inspector.pendingRes': '대기 중인 예약', 'inspector.google': 'Google Maps', + 'inspector.navigation': '내비게이션', + 'inspector.openWith': '다음으로 열기', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': '웹사이트 열기', 'inspector.saveToCollection': '컬렉션에 저장', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index 81b40c34a..03139024f 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -44,7 +44,7 @@ const places: TranslationStrings = { 'places.unplanned': '미계획', 'places.planned': '계획됨', 'places.filterTracks': '트랙', - 'places.sortByRating': '평점순 정렬', + 'places.filterByRating': '평점으로 필터링', 'places.yourRating': '내 평점', 'places.notRated': '아직 평점 없음', 'places.search': '장소 검색...', diff --git a/shared/src/i18n/nl/inspector.ts b/shared/src/i18n/nl/inspector.ts index 6cd2a3479..838afd9b2 100644 --- a/shared/src/i18n/nl/inspector.ts +++ b/shared/src/i18n/nl/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Bevestigde reservering', 'inspector.pendingRes': 'Reservering in behandeling', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigatie', + 'inspector.openWith': 'Openen met', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Website openen', 'inspector.saveToCollection': 'In collectie opslaan', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index b8fa93959..6c0c6f008 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Ongepland', 'places.planned': 'Gepland', 'places.filterTracks': 'Tracks', - 'places.sortByRating': 'Sorteren op beoordeling', + 'places.filterByRating': 'Filteren op beoordeling', 'places.yourRating': 'Jouw beoordeling', 'places.notRated': 'Nog niet beoordeeld', 'places.search': 'Plaatsen zoeken...', diff --git a/shared/src/i18n/pl/inspector.ts b/shared/src/i18n/pl/inspector.ts index b0fe05a29..b5dafafe2 100644 --- a/shared/src/i18n/pl/inspector.ts +++ b/shared/src/i18n/pl/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Potwierdzona rezerwacja', 'inspector.pendingRes': 'Oczekująca rezerwacja', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Nawigacja', + 'inspector.openWith': 'Otwórz w', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Otwórz stronę internetową', 'inspector.saveToCollection': 'Zapisz w kolekcji', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index 21a103ca1..224be55ab 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -36,7 +36,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Niezaplanowane', 'places.planned': 'Zaplanowane', 'places.filterTracks': 'Trasy', - 'places.sortByRating': 'Sortuj według oceny', + 'places.filterByRating': 'Filtruj według oceny', 'places.yourRating': 'Twoja ocena', 'places.notRated': 'Jeszcze nie oceniono', 'places.search': 'Szukaj miejsc...', diff --git a/shared/src/i18n/ru/inspector.ts b/shared/src/i18n/ru/inspector.ts index bb95d7d86..1f5a28940 100644 --- a/shared/src/i18n/ru/inspector.ts +++ b/shared/src/i18n/ru/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Подтверждённое бронирование', 'inspector.pendingRes': 'Ожидающее бронирование', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Навигация', + 'inspector.openWith': 'Открыть в', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Открыть сайт', 'inspector.saveToCollection': 'Сохранить в коллекцию', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index e2392bf37..d154d1eb2 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Незапланированные', 'places.planned': 'Запланированные', 'places.filterTracks': 'Треки', - 'places.sortByRating': 'Сортировать по рейтингу', + 'places.filterByRating': 'Фильтр по оценке', 'places.yourRating': 'Твоя оценка', 'places.notRated': 'Ещё нет оценок', 'places.search': 'Поиск мест...', diff --git a/shared/src/i18n/sv/inspector.ts b/shared/src/i18n/sv/inspector.ts index ec94f53a6..d578fa6de 100644 --- a/shared/src/i18n/sv/inspector.ts +++ b/shared/src/i18n/sv/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Bekräftad bokning', 'inspector.pendingRes': 'Pendlande bokning', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigation', + 'inspector.openWith': 'Öppna med', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Öppna hemsida', 'inspector.saveToCollection': 'Spara i samling', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index b03db9923..4e29e62c8 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Oplanerat', 'places.planned': 'Planerat', 'places.filterTracks': 'Spår', - 'places.sortByRating': 'Sortera efter betyg', + 'places.filterByRating': 'Filtrera efter betyg', 'places.yourRating': 'Ditt betyg', 'places.notRated': 'Inte betygsatt ännu', 'places.search': 'Sök efter platser...', diff --git a/shared/src/i18n/tr/inspector.ts b/shared/src/i18n/tr/inspector.ts index 9ca66ae1b..d3067940c 100644 --- a/shared/src/i18n/tr/inspector.ts +++ b/shared/src/i18n/tr/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Onaylanmış Rezervasyon', 'inspector.pendingRes': 'Bekleyen Rezervasyon', 'inspector.google': "Google Haritalar'da aç", + 'inspector.navigation': 'Navigasyon', + 'inspector.openWith': 'Şununla aç', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.saveToCollection': 'Koleksiyona kaydet', 'inspector.savedToCollection': 'Kaydedildi', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index cd58fd6d3..b6dc3cfe6 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -47,7 +47,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Planlanmamış', 'places.planned': 'Planlanmış', 'places.filterTracks': 'Parçalar', - 'places.sortByRating': 'Puana göre sırala', + 'places.filterByRating': 'Puana göre filtrele', 'places.yourRating': 'Senin puanın', 'places.notRated': 'Henüz puanlanmadı', 'places.search': 'Yer ara...', diff --git a/shared/src/i18n/uk/inspector.ts b/shared/src/i18n/uk/inspector.ts index 04b16ee23..d59e79bf9 100644 --- a/shared/src/i18n/uk/inspector.ts +++ b/shared/src/i18n/uk/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Підтверджене бронювання', 'inspector.pendingRes': 'Очікуване бронювання', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Навігація', + 'inspector.openWith': 'Відкрити в', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Відкрити сайт', 'inspector.saveToCollection': 'Зберегти в колекцію', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index 1f569f769..efde4db2e 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Незаплановані', 'places.planned': 'Заплановані', 'places.filterTracks': 'Треки', - 'places.sortByRating': 'Сортувати за рейтингом', + 'places.filterByRating': 'Фільтр за оцінкою', 'places.yourRating': 'Твоя оцінка', 'places.notRated': 'Ще не оцінено', 'places.search': 'Пошук місць...', diff --git a/shared/src/i18n/vi/inspector.ts b/shared/src/i18n/vi/inspector.ts index 377cbb99e..e63cb2b75 100644 --- a/shared/src/i18n/vi/inspector.ts +++ b/shared/src/i18n/vi/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Đã xác nhận đặt chỗ', 'inspector.pendingRes': 'Đang chờ đặt chỗ', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Điều hướng', + 'inspector.openWith': 'Mở bằng', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.saveToCollection': 'Lưu vào Bộ sưu tập', 'inspector.savedToCollection': 'Đã lưu', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index f0f4b174b..6cbee89bf 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Không có kế hoạch', 'places.planned': 'Có kế hoạch', 'places.filterTracks': 'Bài hát', - 'places.sortByRating': 'Sắp xếp theo đánh giá', + 'places.filterByRating': 'Lọc theo đánh giá', 'places.yourRating': 'Đánh giá của bạn', 'places.notRated': 'Chưa có đánh giá', 'places.search': 'Tìm kiếm địa điểm...', diff --git a/shared/src/i18n/zh-TW/inspector.ts b/shared/src/i18n/zh-TW/inspector.ts index 2fc9be63b..ad4a2444c 100644 --- a/shared/src/i18n/zh-TW/inspector.ts +++ b/shared/src/i18n/zh-TW/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': '已確認預訂', 'inspector.pendingRes': '待確認預訂', 'inspector.google': 'Google Maps', + 'inspector.navigation': '導航', + 'inspector.openWith': '開啟方式', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': '開啟網站', 'inspector.saveToCollection': '儲存到收藏', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index b4cf00fba..b05b4dfcd 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -44,7 +44,7 @@ const places: TranslationStrings = { 'places.unplanned': '未規劃', 'places.planned': '已規劃', 'places.filterTracks': '路線', - 'places.sortByRating': '依評分排序', + 'places.filterByRating': '依評分篩選', 'places.yourRating': '你的評分', 'places.notRated': '尚未評分', 'places.search': '搜尋地點...', diff --git a/shared/src/i18n/zh/inspector.ts b/shared/src/i18n/zh/inspector.ts index daae1d81a..052298667 100644 --- a/shared/src/i18n/zh/inspector.ts +++ b/shared/src/i18n/zh/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': '已确认预订', 'inspector.pendingRes': '待确认预订', 'inspector.google': 'Google 地图', + 'inspector.navigation': '导航', + 'inspector.openWith': '打开方式', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': '打开网站', 'inspector.saveToCollection': '保存到收藏', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index df5478bc2..acc141c99 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -44,7 +44,7 @@ const places: TranslationStrings = { 'places.unplanned': '未规划', 'places.planned': '已规划', 'places.filterTracks': '路线', - 'places.sortByRating': '按评分排序', + 'places.filterByRating': '按评分筛选', 'places.yourRating': '你的评分', 'places.notRated': '暂无评分', 'places.search': '搜索地点...', From 6fe89588c2679068fc2344c9b59d83d3a3c8982e Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:51:02 +0200 Subject: [PATCH 2/4] feat(plugins): give plugins the surface, insets and mobile palette they need (#1886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tell a plugin where it is mounted A plugin sits in one of six host surfaces and cannot see which: a trip tab and the plugin page fill their container and scroll themselves, a dashboard widget and the settings frame report their height, a detail slot is a narrow column in someone else's panel. The contracts differ, and on a filling surface the height report is discarded, so a plugin cannot even discover the difference by trying. trek:context now carries viewport: the surface by name, phone or desktop, the fill flag, and the insets the host already keeps clear. The insets describe space that is held free, not padding to add — the mobile trip tab is the only surface with chrome floating over it, and the numbers come from the same variables the native tabs use so the two cannot drift. * Seat the plugin tab like a native one The mobile plugin tab kept clearance at the bottom and none at the top, so the plugin's first row sat under the status bar and behind the floating back button. The bottom value was a copied 84 that ignored the safe area on the devices that have one, and the padding sat on the container rather than in the scroller. Both clearances now come from the variables TabScroller uses, so the tab cannot drift away from the native ones. The frame also states its surface, and pins color-scheme for the reason both settings mounts already pin it: Chromium paints a white canvas behind a transparent frame otherwise. The tab also had no name anywhere on screen. It is not in the dock, so nothing lit up there either, and the header had a branch for every other tab. It gets the same treatment, built from the tab entry the planner already assembles. * Keep the widget card intact on the phone The card is built from the dashboard's glass vocabulary, which is scoped to .trek-dash. The phone dashboard is a different route and mounts these widgets outside that subtree, so background, border, shadow and blur all resolved empty and the widget stood on the gradient as a bare title over an iframe. Each value now falls back to the mobile token the surrounding cards are made of. * Give the design kit a mobile layer The kit was written for the desktop and stayed there: a filled card with a drop shadow where the mobile design is translucent and border-led, inputs at 13px which make iOS Safari zoom on focus and never zoom back, feedback on :hover which never fires on a touchscreen, and one media query in the whole stylesheet. It reads the new viewport field now and sets data-form-factor, data-surface and data-fill on the document, plus the insets as variables. Everything mobile hangs off data-form-factor="phone", so nothing changes on the desktop: the card loses its shadow and takes the mobile surface tokens, targets grow to 44px, inputs to 16px, and pressing something scales it instead of waiting for a hover that will not come. A filling surface anchors the body and scrolls an inner element, because its height report is discarded by the host. Plus the pieces a v4 screen is actually made of, so an author does not have to reverse-engineer them: eyebrow, count pill, section header, status dot, round icon button, field, segmented control. Each falls back to a global token where the mobile family is absent, so they work on both. * Document what a plugin needs on a phone The guide had nothing on the subject: no mention of the second token family, of who scrolls, of what sizes to expect, or that the kit brings almost nothing responsive. It also promised more than it delivered, claiming the palette was complete and that applying it matches the host "exactly" — fonts are named, not shipped, so that was never true. The new section covers the six surfaces and which of them fill versus report a height, what insets mean (space already held free, not padding to add), the mobile palette with the branch-on-absence pattern, and the four rules that decide whether it feels native. It also says plainly that the dev preview cannot show a phone width yet. --- .../src/components/Planner/DayDetailPanel.tsx | 2 +- .../src/components/Planner/PlaceInspector.tsx | 2 +- .../components/Planner/ReservationsPanel.tsx | 4 +- .../Plugins/PluginContributions.tsx | 2 +- .../components/Plugins/PluginFrame.test.tsx | 52 +++++++ client/src/components/Plugins/PluginFrame.tsx | 95 ++++++++++++- .../src/components/Plugins/PluginWidgets.tsx | 20 ++- .../components/Settings/PluginSettingsTab.tsx | 2 +- .../screens/settings/MSettingsPlugins.tsx | 2 +- client/src/mobile/screens/trip/MTripShell.tsx | 18 +++ .../screens/trip/tabs/MTripTabPanel.tsx | 23 ++- client/src/pages/DashboardPage.tsx | 2 +- client/src/pages/PluginPage.tsx | 2 +- client/src/pages/TripPlannerPage.tsx | 2 +- .../unit/mobile/trip/MTripTabPanel.test.tsx | 20 ++- plugin-sdk/src/ui/kit.ts | 131 ++++++++++++++++++ wiki/Plugin-Development.md | 84 ++++++++++- 17 files changed, 433 insertions(+), 30 deletions(-) diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 1d2ade4d7..e430ad24b 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -342,7 +342,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{dayDetailPlugins.map((p) => (
- +
))}
diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 8d53e9567..a666cde90 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -451,7 +451,7 @@ export default function PlaceInspector({ const tid = (place as { trip_id?: number | string }).trip_id return (
- +
) })} diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index ca204f824..c37d14f1a 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -443,7 +443,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{detailPlugins.map(p => (
- +
))}
@@ -623,7 +623,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> {detailPlugins.map(p => (
- +
))}
diff --git a/client/src/components/Plugins/PluginContributions.tsx b/client/src/components/Plugins/PluginContributions.tsx index d9b87920c..6972e1697 100644 --- a/client/src/components/Plugins/PluginContributions.tsx +++ b/client/src/components/Plugins/PluginContributions.tsx @@ -115,7 +115,7 @@ export function PluginActions({ items, tripId, className }: { items: ViewContrib
- +
diff --git a/client/src/components/Plugins/PluginFrame.test.tsx b/client/src/components/Plugins/PluginFrame.test.tsx index 9aea91891..34b28c50a 100644 --- a/client/src/components/Plugins/PluginFrame.test.tsx +++ b/client/src/components/Plugins/PluginFrame.test.tsx @@ -109,6 +109,58 @@ describe('PluginFrame', () => { await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true)); }); + it('FE-PLUGINS-FRAME-005a: the context says which surface the frame sits in and what shape it takes', () => { + // Without this a plugin cannot tell a full-height tab from a widget that + // reports its own height — and on a filling surface the height report is + // dropped, so it never finds out by trying. + const { container } = render(); + const iframe = container.querySelector('iframe')!; + const posted: Array> = []; + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); + + fromFrame(iframe, { type: 'trek:context:request' }); + + const ctx = posted.find((m) => m.type === 'trek:context') as Record | undefined; + expect(ctx!.viewport).toMatchObject({ surface: 'trip-tab', fill: true, formFactor: 'desktop' }); + expect((ctx!.viewport as { insets: unknown }).insets).toEqual({ top: 0, bottom: 0 }); + }); + + it('FE-PLUGINS-FRAME-005b: a widget reports its own height, and says so', () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + const posted: Array> = []; + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); + + fromFrame(iframe, { type: 'trek:context:request' }); + + const ctx = posted.find((m) => m.type === 'trek:context') as Record | undefined; + expect(ctx!.viewport).toMatchObject({ surface: 'dashboard-widget', fill: false }); + }); + + it('FE-PLUGINS-FRAME-005c: the mobile palette is read off the mobile shell, not the document element', () => { + // The --m-* family is scoped to .m-root. Reading it at documentElement — where + // every other token lives — yields nothing, which is why it never reached a + // plugin before. + const shell = document.createElement('div'); + shell.className = 'm-root'; + shell.style.setProperty('--m-card', 'rgba(255,255,255,.55)'); + shell.style.setProperty('--m-ink', '#101013'); + document.body.appendChild(shell); + try { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + const posted: Array> = []; + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); + + fromFrame(iframe, { type: 'trek:context:request' }); + + const ctx = posted.find((m) => m.type === 'trek:context') as Record | undefined; + expect(ctx!.tokens).toMatchObject({ '--m-card': 'rgba(255,255,255,.55)', '--m-ink': '#101013' }); + } finally { + shell.remove(); + } + }); + it('FE-PLUGINS-FRAME-005: context carries theme tokens, formats and non-secret display identity', () => { const { container } = render(); const iframe = container.querySelector('iframe')!; diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx index c97dde6fd..f773d3731 100644 --- a/client/src/components/Plugins/PluginFrame.tsx +++ b/client/src/components/Plugins/PluginFrame.tsx @@ -10,6 +10,7 @@ import { useToast } from '../shared/Toast' import ConfirmDialog from '../shared/ConfirmDialog' import { pluginsApi } from '../../api/client' import { addListener, removeListener } from '../../api/websocket' +import { useIsPhone } from '../../mobile/useIsPhone' // The design-token contract handed to plugins (#4 richer context): non-secret CSS // values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and @@ -21,6 +22,31 @@ import { addListener, removeListener } from '../../api/websocket' // (--glass-*/--r-*/--sh-*) is intentionally NOT read here: it is scoped to the // dashboard subtree, so it resolves EMPTY at documentElement — the SDK design kit // bakes those values instead (they don't vary with the accent, only light/dark). +/** + * The mobile palette (client/src/mobile/mobile.css) is a SECOND, unrelated token + * family, and it is scoped to `.m-root` rather than to the document element. That + * scope is the reason it cannot simply join the list below: `readThemeTokens` + * measures at documentElement, where every one of these resolves empty — the same + * trap the comment above describes for `--glass-*`. They are read off the mobile + * shell instead, when one is mounted. + * + * A plugin sitting inside the mobile shell needs these to match its surroundings; + * the global palette above describes the desktop chrome and looks foreign there. + */ +const MOBILE_TOKEN_VARS = [ + // ink + ground + '--m-ink', '--m-muted', '--m-faint', '--m-bg', '--m-scr', + // glass surfaces the mobile design is built from + '--m-glass', '--m-gbr', '--m-card', '--m-cbr', '--m-inner', '--m-inbr', + '--m-sheet', '--m-sheetop', '--m-shbr', + // action + accents + '--m-act', '--m-actfg', '--m-dim', '--m-ic', '--m-rowbr', '--m-avbr', '--m-knob', + // status canon (theme independent, but a plugin should not restate it) + '--m-st-confirmed', '--m-st-pending', '--m-st-info', '--m-st-danger', '--m-st-neutral', + // chrome metrics + '--m-safe-top', +] + const TOKEN_VARS = [ // surfaces '--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-elevated', @@ -39,6 +65,9 @@ const TOKEN_VARS = [ // radii, type, misc '--radius-sm', '--radius-md', '--radius-lg', '--radius-xl', '--font-system', '--font-subtext', '--overlay', '--ease-out-quint', + // How much a host surface keeps clear at the bottom. Global, unlike the rest of + // the mobile metrics, and useful on both form factors. + '--bottom-nav-h', ] function readThemeTokens(): Record { const cs = getComputedStyle(document.documentElement) @@ -47,9 +76,54 @@ function readThemeTokens(): Record { const val = cs.getPropertyValue(v).trim() if (val) out[v] = val } + // Measured on the mobile shell, because that is where the mobile palette is + // declared. Absent on desktop, and then these keys are simply absent too — a + // plugin checks for them rather than assuming both families are present. + const mobileRoot = document.querySelector('.m-root') + if (mobileRoot) { + const mcs = getComputedStyle(mobileRoot) + for (const v of MOBILE_TOKEN_VARS) { + const val = mcs.getPropertyValue(v).trim() + if (val) out[v] = val + } + } return out } +/** Where a frame is mounted. Part of the plugin contract, so the names are stable. */ +export type PluginSurface = + | 'trip-tab' // a plugin's own tab inside a trip (fills, scrolls itself) + | 'plugin-page' // /plugins/:id (fills, scrolls itself) + | 'dashboard-widget'// a card on the dashboard (reports its height) + | 'user-settings' // the plugin's settings.html (reports its height) + | 'detail-slot' // inside a place/day/reservation panel (reports its height) + | 'action-frame' // the modal a table action opens (fills) + +/** + * What the host already keeps clear around this frame, in CSS pixels. + * + * This describes space the plugin does NOT have to account for; it is not a + * request to add padding. The mobile trip tab is the only surface with floating + * chrome above and below it, and the numbers come from the same variables the + * native tabs use, so the two can never drift apart. + */ +function readInsets(surface: PluginSurface | undefined, phone: boolean): { top: number; bottom: number } { + if (!phone || surface !== 'trip-tab') return { top: 0, bottom: 0 } + const mobileRoot = document.querySelector('.m-root') + if (!mobileRoot) return { top: 0, bottom: 0 } + const cs = getComputedStyle(mobileRoot) + const px = (value: string, fallback: number) => { + const n = Number.parseFloat(value) + return Number.isFinite(n) ? n : fallback + } + // Mirrors TabScroller: the controls end 58px below the safe-top anchor, and the + // dock plus its breathing room is what --bottom-nav-h already expresses. + return { + top: px(cs.getPropertyValue('--m-safe-top'), 12) + 58, + bottom: px(cs.getPropertyValue('--bottom-nav-h'), 84) + 22, + } +} + /** * The host's current appearance state, mirrored from the attributes applyAppearance * writes on , so a plugin can honour the same accessibility/appearance choices @@ -97,6 +171,13 @@ interface PluginFrameProps { * below — right for dashboard widgets, wrong for a page. */ fill?: boolean + /** + * Which host surface this frame is mounted in. Handed to the plugin verbatim, + * because the contract differs per surface and a plugin cannot see where it + * sits: a page fills and scrolls itself, a widget reports its height and gets + * it honoured, a detail slot is a narrow column inside someone else's panel. + */ + surface?: PluginSurface className?: string title?: string /** @@ -208,7 +289,8 @@ function createPluginSessionStorageKeys( } } -export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, className, title, path = 'index.html' }: PluginFrameProps) { +export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, surface, className, title, path = 'index.html' }: PluginFrameProps) { + const isPhone = useIsPhone() const frameRef = useRef(null) // A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its // window identity keeps matching our iframe afterwards. Track loads and refuse @@ -282,6 +364,15 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d // mirror the same look and accessibility choices as the host. user: userName != null ? { name: userName, avatar: userAvatar, isAdmin } : null, appearance: readAppearance(), + // Where this frame sits and what shape it is expected to take. Without it a + // plugin cannot tell a full-height tab from a widget that reports its own + // height, and a height report is silently dropped on a filling surface. + viewport: { + surface: surface ?? null, + formFactor: isPhone ? 'phone' : 'desktop', + fill, + insets: readInsets(surface, isPhone), + }, formats: { locale, currency: displayCurrency, @@ -292,7 +383,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d blurBookingCodes: Boolean(settings.blur_booking_codes), }, tokens: readThemeTokens(), - }), [tripId, placeId, dayId, reservationId, userId, locale, userName, userAvatar, isAdmin, settings]) + }), [tripId, placeId, dayId, reservationId, userId, locale, userName, userAvatar, isAdmin, settings, surface, fill, isPhone]) useEffect(() => { const frame = frameRef.current diff --git a/client/src/components/Plugins/PluginWidgets.tsx b/client/src/components/Plugins/PluginWidgets.tsx index 663e31d8e..57a849a5f 100644 --- a/client/src/components/Plugins/PluginWidgets.tsx +++ b/client/src/components/Plugins/PluginWidgets.tsx @@ -18,13 +18,19 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act {plugins.map((p) => (
@@ -32,7 +38,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '16px 20px 8px', fontSize: 13, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.14em', - color: 'var(--ink-3)', + color: 'var(--ink-3, var(--m-faint))', }} > @@ -40,7 +46,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
{/* min-height is just a pre-resize floor; trek:resize drives the real height. */}
- +
))} diff --git a/client/src/components/Settings/PluginSettingsTab.tsx b/client/src/components/Settings/PluginSettingsTab.tsx index 2f58c0a6e..638054c1f 100644 --- a/client/src/components/Settings/PluginSettingsTab.tsx +++ b/client/src/components/Settings/PluginSettingsTab.tsx @@ -242,7 +242,7 @@ function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; ic white canvas behind the transparent frame (same trap .hero-overlay-frame guards against), which glares in dark mode. */}
- +
) diff --git a/client/src/mobile/screens/settings/MSettingsPlugins.tsx b/client/src/mobile/screens/settings/MSettingsPlugins.tsx index a6dc4efa6..8fd2c4498 100644 --- a/client/src/mobile/screens/settings/MSettingsPlugins.tsx +++ b/client/src/mobile/screens/settings/MSettingsPlugins.tsx @@ -288,7 +288,7 @@ function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; ic white canvas behind the transparent frame (same trap .hero-overlay-frame guards against), which glares in dark mode. */}
- +
) diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index ef20429d5..1526f7eda 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -458,6 +458,24 @@ export default function MTripShell({ )} + {/* A plugin tab is the only one that used to arrive without a name: it is + not in the dock, so nothing was lit up there either, and the screen + gave no clue which plugin was open. Same treatment as the others, from + the tab entry the planner already builds (id, label, icon). */} + {trTab.startsWith('plugin:') && (() => { + const tab = planner.TRIP_TABS.find(x => x.id === trTab) + if (!tab) return null + const Icon = tab.icon + return ( +
+
+ {Icon && } + {tab.label} +
+
+ ) + })()} + {trTab === 'plan' ? ( - +
+
) } diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index be8e70887..f1613eb9c 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -419,7 +419,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch {heroPlugins.length > 0 && ( )} diff --git a/client/src/pages/PluginPage.tsx b/client/src/pages/PluginPage.tsx index 3b2d5f69f..cf403adeb 100644 --- a/client/src/pages/PluginPage.tsx +++ b/client/src/pages/PluginPage.tsx @@ -27,7 +27,7 @@ export default function PluginPage(): React.ReactElement { return (
- +
) diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 463b8005d..200b6ef67 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -784,7 +784,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {activeTab.startsWith('plugin:') && (
- +
)} diff --git a/client/tests/unit/mobile/trip/MTripTabPanel.test.tsx b/client/tests/unit/mobile/trip/MTripTabPanel.test.tsx index 57a429251..f211158ef 100644 --- a/client/tests/unit/mobile/trip/MTripTabPanel.test.tsx +++ b/client/tests/unit/mobile/trip/MTripTabPanel.test.tsx @@ -20,8 +20,8 @@ vi.mock('../../../../src/mobile/screens/trip/tabs/MFilesTab', () => ({ default: vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabTab', () => ({ default: stub('collab') })) vi.mock('../../../../src/mobile/screens/trip/tabs/MListsTab', () => ({ default: stub('lists') })) vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({ - default: ({ pluginId, tripId, fill }: { pluginId: string; tripId: string | null; fill?: boolean }) => ( -
+ default: ({ pluginId, tripId, fill, surface }: { pluginId: string; tripId: string | null; fill?: boolean; surface?: string }) => ( +
), })) @@ -71,8 +71,20 @@ describe('MTripTabPanel', () => { expect(frame).toHaveAttribute('data-plugin', 'trip-todos') expect(frame).toHaveAttribute('data-trip', '1') expect(frame).toHaveAttribute('data-fill', 'true') - // The frame keeps the dock clearance the scroll body uses. - expect((frame.parentElement as HTMLElement).style.paddingBottom).toContain('safe-area-inset-bottom') + // Both clearances come from the variables TabScroller uses, so the frame + // cannot drift away from the native tabs. The top one was missing entirely, + // which put the plugin's first row under the floating back button; the bottom + // one was a hard-coded 84 that ignored the safe area. + const wrapper = frame.parentElement as HTMLElement + expect(wrapper.className).toContain('--m-safe-top') + expect(wrapper.className).toContain('--bottom-nav-h') + }) + + it('FE-MOB-TABPANEL-007b: tells the plugin which surface it is mounted in', () => { + // A trip tab fills and scrolls itself; a widget reports its height. The plugin + // cannot see the difference from inside the frame. + renderTab('plugin:trip-todos') + expect(screen.getByTestId('plugin-frame')).toHaveAttribute('data-surface', 'trip-tab') }) it('FE-MOB-TABPANEL-008: passes a null tripId to the plugin frame before the trip is known', () => { diff --git a/plugin-sdk/src/ui/kit.ts b/plugin-sdk/src/ui/kit.ts index 778ebd2d6..ac09c6758 100644 --- a/plugin-sdk/src/ui/kit.ts +++ b/plugin-sdk/src/ui/kit.ts @@ -279,6 +279,125 @@ body.trek-ui { @media (max-width: 639px) { .trek-modal-enter { animation: trek-drawer-enter 320ms cubic-bezier(0.32, 0.72, 0, 1); } } + +/* Mobile layer ------------------------------------------------------------- * + * TREK 4 gives the phone its own design: a translucent, border-led surface over + * a gradient, no drop shadows, and touch feedback rather than hover. The host + * hands the mobile palette (--m-*) down with the rest of the tokens whenever a + * frame is mounted inside the mobile shell, so these rules only restate the + * SHAPE — the colours come from the same variables the native screens use. + * + * Everything here is keyed on [data-form-factor="phone"], which the host sets + * from the viewport it reports. On desktop not a single rule below applies. */ + +[data-form-factor="phone"] .trek-ui, +[data-form-factor="phone"] body.trek-ui { font-size: 15px; } + +/* The card is the workhorse. On the phone it is a translucent tile with a hairline + border and no shadow — a drop shadow over the gradient reads as a sticker. */ +[data-form-factor="phone"] .trek-card { + background: var(--m-card, var(--bg-card)); + border: 1px solid var(--m-cbr, var(--border-primary)); + border-radius: 16px; + box-shadow: none; + padding: 14px; + -webkit-backdrop-filter: blur(30px) saturate(1.8); + backdrop-filter: blur(30px) saturate(1.8); +} +[data-form-factor="phone"] .trek-glass { + background: var(--m-glass, var(--glass-bg)); + border-color: var(--m-gbr, var(--glass-border)); + border-radius: 20px; + box-shadow: none; + padding: 16px; +} + +/* Touch has no hover. The lift on hover never fires on a phone and the pressed + state never existed, so a tap gave no feedback at all. */ +[data-form-factor="phone"] .trek-card.trek-interactive:hover, +[data-form-factor="phone"] .trek-glass.trek-interactive:hover { transform: none; box-shadow: none; } +[data-form-factor="phone"] .trek-interactive:active, +[data-form-factor="phone"] .trek-btn:active, +[data-form-factor="phone"] .trek-row:active { transform: scale(.985); opacity: .9; } +[data-form-factor="phone"] .trek-btn:hover { filter: none; } + +/* 44px is the smallest target that reliably hits on a phone. */ +[data-form-factor="phone"] .trek-btn { min-height: 44px; border-radius: 12px; } +[data-form-factor="phone"] .trek-row { min-height: 48px; } + +/* An input below 16px makes iOS Safari zoom the page on focus, and the zoom does + not come back. This is the single most common mobile bug in embedded UI. */ +[data-form-factor="phone"] .trek-input, +[data-form-factor="phone"] .trek-select, +[data-form-factor="phone"] .trek-textarea { font-size: 16px; min-height: 44px; border-radius: 12px; } + +/* Ink follows the mobile palette where it exists. */ +[data-form-factor="phone"] .trek-title { color: var(--m-ink, var(--text-primary)); } +[data-form-factor="phone"] .trek-sub, +[data-form-factor="phone"] .trek-muted { color: var(--m-muted, var(--text-muted)); } + +/* A filling surface owns its own scrolling: the host will not scroll for it, and + a height report is discarded. Anchor the body and scroll the content. */ +[data-fill] .trek-ui, [data-fill] body.trek-ui { height: 100%; overflow: hidden; } +[data-fill] .trek-scroll { + height: 100%; overflow-y: auto; -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +} + +/* Building blocks the native mobile screens are made of, so a plugin does not + have to reverse-engineer them from screenshots. */ +.trek-eyebrow { + font-size: 10px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; + color: var(--m-faint, var(--text-faint)); +} +.trek-countpill { + display: inline-flex; align-items: center; justify-content: center; + min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; + background: var(--m-ic, var(--bg-tertiary)); color: var(--m-muted, var(--text-muted)); + font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; +} +.trek-sectionhead { + display: flex; align-items: center; gap: 7px; width: 100%; + margin: 15px 0 2px; padding: 0 2px; background: none; border: 0; + font: inherit; text-align: left; cursor: pointer; +} +.trek-statusdot { width: 8px; height: 8px; border-radius: 999px; flex: none; background: var(--m-st-neutral, var(--text-faint)); } +.trek-statusdot[data-status="confirmed"] { background: var(--m-st-confirmed, var(--success)); } +.trek-statusdot[data-status="pending"] { background: var(--m-st-pending, var(--warning)); } +.trek-statusdot[data-status="info"] { background: var(--m-st-info, var(--info)); } +.trek-statusdot[data-status="danger"] { background: var(--m-st-danger, var(--danger)); } +.trek-iconbtn { + display: inline-grid; place-items: center; flex: none; + width: 38px; height: 38px; border-radius: 999px; cursor: pointer; + background: var(--m-ic, var(--bg-tertiary)); + border: 1px solid var(--m-gbr, var(--border-primary)); + color: var(--m-ink, var(--text-primary)); +} +.trek-iconbtn:active { transform: scale(.94); } +.trek-field { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.trek-field > .trek-field-label { + font-size: 9px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; + color: var(--m-faint, var(--text-faint)); +} +.trek-field > .trek-field-value { + font-size: 13px; font-weight: 600; color: var(--m-ink, var(--text-primary)); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.trek-segmented { + display: inline-flex; align-items: center; gap: 2px; padding: 3px; border-radius: 999px; + background: var(--m-glass, var(--bg-tertiary)); + border: 1px solid var(--m-gbr, var(--border-primary)); +} +.trek-segmented > button { + border: 0; background: none; cursor: pointer; font: inherit; + padding: 8px 16px; border-radius: 999px; white-space: nowrap; + font-size: 13px; font-weight: 500; color: var(--m-ink, var(--text-primary)); +} +.trek-segmented > button[aria-pressed="true"], +.trek-segmented > button.on { + background: var(--m-act, var(--accent)); color: var(--m-actfg, var(--accent-text)); font-weight: 600; +} + .trek-backdrop-enter { animation: trek-backdrop-enter 180ms var(--trek-ease-quint); } .trek-toast-enter { animation: trek-toast-enter 260ms var(--trek-ease-quint); will-change: transform, opacity; } .trek-pie-reveal { @@ -371,6 +490,18 @@ export const TREK_THEME_JS = `(function () { if (m.theme) { docEl.setAttribute('data-theme', m.theme); } if (m.locale) { docEl.setAttribute('lang', m.locale); } docEl.setAttribute('dir', m.dir === 'rtl' ? 'rtl' : 'ltr'); + // Where this frame sits, and in what shape. data-form-factor drives the + // mobile layer of this stylesheet; data-surface lets a plugin tell a + // full-height tab from a widget that reports its height. The insets are the + // space the HOST already keeps clear — exposed so a plugin can align its own + // sticky chrome with the host's, not so it adds padding on top. + var vp = m.viewport || {}; + if (vp.formFactor) { docEl.setAttribute('data-form-factor', vp.formFactor); } + if (vp.surface) { docEl.setAttribute('data-surface', vp.surface); } + setFlag('data-fill', vp.fill); + var ins = vp.insets || {}; + docEl.style.setProperty('--trek-inset-top', (ins.top || 0) + 'px'); + docEl.style.setProperty('--trek-inset-bottom', (ins.bottom || 0) + 'px'); var t = m.tokens || {}; for (var k in t) { if (Object.prototype.hasOwnProperty.call(t, k) && t[k]) { docEl.style.setProperty(k, t[k]); } diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index c290545c3..327f18a87 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -551,13 +551,15 @@ declared), no popups. ### Matching the TREK look by hand (`m.tokens`) -`tokens` is the whole global palette resolved for the **current** theme — surfaces +`tokens` is the global palette resolved for the **current** theme — surfaces (`--bg-card`, `--bg-hover`, …), text (`--text-primary`/`-secondary`/`-muted`/`-faint`), borders, the **accent family** (`--accent`, `--accent-text`, `--accent-hover`, `--accent-subtle`), semantic + soft fills (`--success`/`--danger`/`--warning`/`--info` `-soft`), shadows (`--shadow-*`), radii (`--radius-*`) and fonts (`--font-system`). -Apply them as CSS variables and your UI matches the host exactly — in both themes and -under a custom accent or high-contrast — instead of hard-coding a palette that drifts: +Apply them as CSS variables and your UI follows the host through a theme toggle, a +custom accent and high-contrast, instead of hard-coding a palette that drifts. (Fonts +are named, not shipped: the files live in the host bundle, so declare your own or fall +back to a system stack.) ```js function applyContext(m) { @@ -579,6 +581,82 @@ bakes those, since they only change with light/dark, not the accent.) Honour and auto-size to the height you report via `trek:resize`, so render flush and transparent — the design kit reports your height for you. +## On a phone + +TREK 4 gives the phone its own design, and your plugin is mounted inside it. Two things +follow from that, and both arrive in `trek:context` under `viewport`: + +```js +m.viewport = { + surface: 'trip-tab', // where you are mounted, see the table below + formFactor: 'phone', // 'phone' | 'desktop' + fill: true, // true = fill the host container, false = report your height + insets: { top: 82, bottom: 106 }, // px the host ALREADY keeps clear around you +} +``` + +### Know which surface you are on + +| `surface` | Shape | Notes | +|---|---|---| +| `trip-tab` | fills | Your own tab in a trip. You scroll yourself. | +| `plugin-page` | fills | `/plugins/:id`. You scroll yourself. | +| `dashboard-widget` | reports height | A card on the dashboard. | +| `user-settings` | reports height | Your `settings.html`. | +| `detail-slot` | reports height | A narrow column inside a place/day/reservation panel. | +| `action-frame` | fills | The modal a table action opens. | + +This matters because the contract differs: on a **filling** surface your `trek:resize` +height is deliberately ignored, so give your document `height: 100%` and put the +scrolling on an inner element. On a **reporting** surface let the content decide the +height and never set `100%`, or the host reserves a screenful for two lines of text. +The design kit does both for you when it sees `viewport.fill`. + +### `insets` is not padding you have to add + +It is the space the host is *already* keeping clear. On the mobile trip tab, floating +controls hover above your frame and a translucent dock hovers below it, and the host +holds both areas free for you. Use the numbers to line your own sticky header up with +the host's chrome, not to add margin — that would double the gap. + +### The mobile palette + +Inside the mobile shell, `tokens` carries a **second** family alongside the global one: +`--m-ink`, `--m-muted`, `--m-faint`, `--m-bg`, `--m-card`, `--m-cbr`, `--m-glass`, +`--m-gbr`, `--m-inner`, `--m-act`, `--m-actfg`, `--m-ic`, `--m-rowbr`, the status canon +(`--m-st-confirmed`, `--m-st-pending`, `--m-st-info`, `--m-st-danger`, `--m-st-neutral`) +and `--m-safe-top`. These are what the native mobile screens are built from; the global +palette describes the desktop chrome and looks foreign next to them. + +They are **absent on desktop**, so branch on their presence rather than assuming both +families are there: + +```css +.card { + background: var(--m-card, var(--bg-card)); + border: 1px solid var(--m-cbr, var(--border-primary)); +} +``` + +### Four rules that decide whether it feels native + +1. **Inputs at 16px or larger.** Below that, iOS Safari zooms the page on focus and does + not zoom back. This is the most common mobile bug in embedded UI. +2. **Touch feedback on `:active`, not `:hover`.** A hover rule never fires on a phone, so + a tap gives no response at all. +3. **Targets at least 44px.** Anything smaller is a coin toss with a thumb. +4. **No drop shadows on the mobile surface.** The design is translucent and border-led + over a gradient; a shadow reads as a sticker glued on top. + +The design kit applies all four automatically once `data-form-factor="phone"` is set, +which it does from `viewport`. If you style everything yourself, they are on you. + +### Seeing it + +`trek-plugin dev` previews at a desktop size. Until that grows a phone width, resize the +browser window and use your browser's device emulation — the frame is a real browsing +context, so media queries inside it measure the frame, and they work. + ## Settings Declare settings in the manifest; TREK renders the form (you write no settings From a39c31cb489c7aa60e7024b13061ee5c94248826 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:04:09 +0200 Subject: [PATCH 3/4] Hide permanently closed places from search (#1887) A place that has shut down for good is never the answer to "where should we go", so it should not be in the results at all. Google knows this and says so in businessStatus, we just never asked for the field. It goes into the field mask and CLOSED_PERMANENTLY drops out. CLOSED_TEMPORARILY stays - a restaurant on holiday next month is still worth planning around - and results without the field are parks and viewpoints rather than businesses, so they stay too. OSM states it a few different ways. Re-tagged objects (disused:amenity, abandoned:shop) never matched the selectors to begin with, but plenty keep their original tag and gain a marker instead, and those did come back. Same for opening_hours=closed/off. --- server/src/nest/maps/maps.service.ts | 19 ++++++++-- server/tests/unit/nest/maps.service.test.ts | 40 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 8a997aba9..abe08c8fc 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -260,6 +260,8 @@ export interface CommonsCandidate { interface GooglePlaceResult { id: string; displayName?: { text: string }; + /** OPERATIONAL | CLOSED_TEMPORARILY | CLOSED_PERMANENTLY. Absent on non-business results. */ + businessStatus?: string; formattedAddress?: string; location?: { latitude: number; longitude: number }; rating?: number; @@ -806,6 +808,13 @@ export class MapsService { const tags = el.tags || {}; const name = tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || null; if (!name) continue; // unnamed POIs aren't useful to add to a plan + // A shut-down place is not somewhere to plan a visit (#1341). OSM usually + // re-tags one with a `disused:`/`abandoned:` prefix, and those never match + // the selectors above — but plenty keep their original tag and gain a marker + // instead, and those do come back. `opening_hours=closed`/`off` is the same + // statement in the hours field. + if (tags.disused === 'yes' || tags.abandoned === 'yes') continue; + if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue; const lat = el.lat ?? el.center?.lat; const lng = el.lon ?? el.center?.lon; if (lat == null || lng == null) continue; @@ -1404,7 +1413,7 @@ export class MapsService { 'Content-Type': 'application/json', 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': - 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri', + 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus', }, body: JSON.stringify(searchBody), }); @@ -1417,7 +1426,13 @@ export class MapsService { throw err; } - const places = (data.places || []).map((p: GooglePlaceResult) => ({ + // A place that has shut down for good is never the answer to "where should we + // go" (#1341). Temporarily closed stays: a restaurant on holiday next month is + // still worth planning around. Anything without the field is a non-business + // result (a park, a viewpoint) and is kept. + const places = (data.places || []) + .filter((p: GooglePlaceResult) => p.businessStatus !== 'CLOSED_PERMANENTLY') + .map((p: GooglePlaceResult) => ({ google_place_id: p.id, google_ftid: googleFtidFromMapsUrl(p.googleMapsUri), name: p.displayName?.text || '', diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index f31587166..3f7a5bb2b 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -804,6 +804,25 @@ describe('searchOverpassPois localized names (#1655)', () => { expect(pois[0].name).toBe('Elephant Obelisk'); }); + it('skips POIs tagged as closed for good (#1341)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + elements: [ + { type: 'node', id: 1, lat: 41.9, lon: 12.48, tags: { name: 'Gone Forever', tourism: 'attraction', disused: 'yes' } }, + { type: 'node', id: 2, lat: 41.9, lon: 12.49, tags: { name: 'Ruin', tourism: 'attraction', abandoned: 'yes' } }, + { type: 'node', id: 3, lat: 41.9, lon: 12.5, tags: { name: 'Shut', tourism: 'attraction', opening_hours: 'closed' } }, + { type: 'node', id: 4, lat: 41.9, lon: 12.51, tags: { name: 'Open For Business', tourism: 'attraction' } }, + ], + }), + }), + ); + const { pois } = await svc.searchOverpassPois('sights', bbox(5), 'en-US'); + expect(pois.map((p: any) => p.name)).toEqual(['Open For Business']); + }); + it('falls back to the native name when no localized tag exists', async () => { stubOverpass({ name: tags.name, tourism: 'attraction' }); const { pois } = await svc.searchOverpassPois('sights', bbox(4), 'fr-FR'); @@ -1162,6 +1181,27 @@ describe('searchPlaces (fetch stubbed)', () => { expect(place.phone).toBeNull(); }); + it('MAPS-183: drops permanently closed Google results and keeps the rest (#1341)', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + places: [ + { id: 'gone', displayName: { text: 'Shuttered Bistro' }, businessStatus: 'CLOSED_PERMANENTLY' }, + { id: 'holiday', displayName: { text: 'Winter Break Cafe' }, businessStatus: 'CLOSED_TEMPORARILY' }, + { id: 'open', displayName: { text: 'Still Trading' }, businessStatus: 'OPERATIONAL' }, + { id: 'park', displayName: { text: 'City Park' } }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + const result = await svc.searchPlaces(1, 'bistro'); + expect(result.places.map((p: any) => p.google_place_id)).toEqual(['holiday', 'open', 'park']); + // The field has to be requested or it never arrives to filter on. + const mask = (fetchMock.mock.calls[0][1].headers as Record)['X-Goog-FieldMask']; + expect(mask).toContain('places.businessStatus'); + }); + it('MAPS-107: keeps 0 coordinates (equator / prime meridian) instead of nulling them', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); vi.stubGlobal( From 435b782c856c4b410405f7f6467a478f0fb83bb9 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:13:07 +0200 Subject: [PATCH 4/4] Add a note field to expenses (#1888) Requested in discussion #1658: an expense carries an amount and a category, and everything else about it ends up crammed into the title. The column was already there. It just was not available, because the costs UI stores an itemized receipt in it as TICKETJSON:{...}, so an expense split by receipt could never carry a note - and saving one any other way wrote note=null, which quietly wiped whatever had been typed into the budget table. The receipt gets its own column and note goes back to being text. Old clients that still send the blob as a note are routed to the right column instead of clobbering the note. On desktop the row now has fixed columns, so the amount does not drift left when one row has a note and the next does not. The category moved to the card edge as a tab like the mobile card does, which is what frees the line the note sits on. Collapsed it is one line, and the same element grows in place when opened. The dialog is two columns now: what the expense was on the left, how it is shared on the right, grouped into panels the way the trip dialog does it. On the phone the eleven category pills became a dropdown, which is where the room for the note field came from, and a note on a card unfolds when you tap it. --- .../Budget/CostsPanel.helpers.test.ts | 58 ++++- .../components/Budget/CostsPanel.helpers.ts | 47 ++++ .../src/components/Budget/CostsPanel.test.tsx | 77 ++++++- client/src/components/Budget/CostsPanel.tsx | 214 ++++++++++++------ .../mobile/screens/trip/sheets/MCostSheet.tsx | 99 ++++---- .../mobile/screens/trip/tabs/MCostsTab.tsx | 26 ++- .../mobile/screens/trip/tabs/costsModel.ts | 5 +- .../unit/mobile/trip/MCostSheet.test.tsx | 56 ++++- server/src/db/migrations.ts | 15 ++ server/src/nest/budget/budget.service.ts | 42 +++- server/src/types.ts | 2 + .../tests/unit/nest/budget.service.db.test.ts | 78 +++++++ shared/src/budget/budget.schema.ts | 4 + shared/src/i18n/ar/budget.ts | 5 + shared/src/i18n/br/budget.ts | 5 + shared/src/i18n/ca/budget.ts | 5 + shared/src/i18n/cs/budget.ts | 5 + shared/src/i18n/de/budget.ts | 5 + shared/src/i18n/en/budget.ts | 5 + shared/src/i18n/es/budget.ts | 5 + shared/src/i18n/fr/budget.ts | 5 + shared/src/i18n/gr/budget.ts | 5 + shared/src/i18n/hu/budget.ts | 5 + shared/src/i18n/id/budget.ts | 5 + shared/src/i18n/it/budget.ts | 5 + shared/src/i18n/ja/budget.ts | 5 + shared/src/i18n/ko/budget.ts | 5 + shared/src/i18n/nl/budget.ts | 5 + shared/src/i18n/pl/budget.ts | 5 + shared/src/i18n/ru/budget.ts | 5 + shared/src/i18n/sv/budget.ts | 5 + shared/src/i18n/tr/budget.ts | 5 + shared/src/i18n/uk/budget.ts | 5 + shared/src/i18n/vi/budget.ts | 5 + shared/src/i18n/zh-TW/budget.ts | 5 + shared/src/i18n/zh/budget.ts | 5 + 36 files changed, 712 insertions(+), 126 deletions(-) diff --git a/client/src/components/Budget/CostsPanel.helpers.test.ts b/client/src/components/Budget/CostsPanel.helpers.test.ts index 80ac00812..ec2d15cb6 100644 --- a/client/src/components/Budget/CostsPanel.helpers.test.ts +++ b/client/src/components/Budget/CostsPanel.helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { splitCents, payerSum, payersBalanced, rebalancePayers } from './CostsPanel.helpers' +import { hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems } from './CostsPanel.helpers' describe('splitCents', () => { it('splits evenly when it divides cleanly', () => { @@ -76,3 +76,59 @@ describe('rebalancePayers', () => { expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true) }) }) + +// ── Notes vs. itemized receipts (#1658) ────────────────────────────────────── + +describe('readTicketItems', () => { + const receipt = '{"items":[{"name":"Bread","price":"3.50","parts":[1,2]},{"name":"Wine","price":"12","parts":[2]}]}' + + it('reads the receipt out of its own column', () => { + const items = readTicketItems({ ticket_json: receipt }) + expect(items.map(i => i.name)).toEqual(['Bread', 'Wine']) + expect(items[0].participants).toEqual(new Set([1, 2])) + expect(items[1].price).toBe('12') + }) + + it('still reads a response cached before the column existed', () => { + const items = readTicketItems({ note: 'TICKETJSON:' + receipt }) + expect(items).toHaveLength(2) + expect(items[1].participants).toEqual(new Set([2])) + }) + + it('prefers the column over the legacy note when both are present', () => { + const items = readTicketItems({ ticket_json: '{"items":[{"name":"Only","price":"1","parts":[]}]}', note: 'TICKETJSON:' + receipt }) + expect(items.map(i => i.name)).toEqual(['Only']) + }) + + it('is empty for an expense with neither, and for malformed JSON', () => { + expect(readTicketItems({ note: 'just a note' })).toEqual([]) + expect(readTicketItems(null)).toEqual([]) + expect(readTicketItems({ ticket_json: '{oops' })).toEqual([]) + }) + + it('round-trips through writeTicketItems', () => { + const items = readTicketItems({ ticket_json: receipt }) + expect(readTicketItems({ ticket_json: writeTicketItems(items) })).toEqual(items) + }) +}) + +describe('hasTicketSplit', () => { + it('recognises both storage shapes and nothing else', () => { + expect(hasTicketSplit({ ticket_json: '{"items":[]}' })).toBe(true) + expect(hasTicketSplit({ note: 'TICKETJSON:{"items":[]}' })).toBe(true) + expect(hasTicketSplit({ note: 'dinner with Ben' })).toBe(false) + expect(hasTicketSplit(undefined)).toBe(false) + }) +}) + +describe('readUserNote', () => { + it('returns what the user typed', () => { + expect(readUserNote({ note: 'Lisa pays half back' })).toBe('Lisa pays half back') + }) + + it('never returns a receipt blob as if it were a note', () => { + expect(readUserNote({ note: 'TICKETJSON:{"items":[]}' })).toBe('') + expect(readUserNote({ note: null })).toBe('') + expect(readUserNote(null)).toBe('') + }) +}) diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts index 8e5c94042..74e6d631e 100644 --- a/client/src/components/Budget/CostsPanel.helpers.ts +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -88,6 +88,53 @@ export interface TicketItem { participants: Set } +/** + * Read the itemized receipt off an expense (#1658). + * + * It lives in `ticket_json` since migration 186. Before that it was smuggled + * through `note` behind a `TICKETJSON:` prefix, which is why an expense split by + * receipt could never carry a written note. The old shape is still read here so + * a response cached before the migration keeps rendering its split. + */ +export function readTicketItems(item: { ticket_json?: string | null; note?: string | null } | null | undefined): TicketItem[] { + const raw = item?.ticket_json ?? (item?.note?.startsWith('TICKETJSON:') ? item.note.slice(11) : null) + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + return (parsed.items || []).map((line: { name?: string; price?: unknown; parts?: number[] }, i: number) => ({ + id: `${i}`, + name: String(line.name ?? ''), + price: String(line.price ?? ''), + participants: new Set(line.parts || []), + })) + } catch { + return [] + } +} + +/** True when this expense is split line by line rather than equally or by amount. */ +export function hasTicketSplit(item: { ticket_json?: string | null; note?: string | null } | null | undefined): boolean { + return Boolean(item?.ticket_json || item?.note?.startsWith('TICKETJSON:')) +} + +/** Serialize the receipt lines for storage in `ticket_json`. */ +export function writeTicketItems(items: TicketItem[]): string { + return JSON.stringify({ items: items.map(i => ({ name: i.name, price: i.price, parts: [...i.participants] })) }) +} + +/** Long enough for a receipt discrepancy or a reimbursement reminder, short + * enough that the row stays a row. */ +export const NOTE_MAX = 500 + +/** + * The note a user actually typed, which is never the receipt blob. Only matters + * for data written before migration 186 moved the receipt out of the field. + */ +export function readUserNote(item: { note?: string | null } | null | undefined): string { + const note = item?.note + return note && !note.startsWith('TICKETJSON:') ? note : '' +} + /** Per-person totals for a receipt split line by line, plus the receipt total. */ export function calculateTicketShares(items: TicketItem[]): { shares: Record; total: number } { const shares: Record = {} diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 6e42b259c..6dd9ec25c 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -171,6 +171,79 @@ describe('CostsPanel — settlements in the ledger', () => { expect(screen.getByText('Unfinished')).toBeInTheDocument() }) + // ── Notes on an expense (#1658) ──────────────────────────────────────────── + + it('shows a note on the row and expands it on click', async () => { + const long = 'Bought the whole week of breakfasts here, Ben pays back his half once the card statement lands' + const item = { + ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Supermarket' }), + total_price: 60, + note: long, + payers: [{ user_id: 1, amount: 60, username: 'alice' }], + } + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + ) + const user = userEvent.setup() + render() + + // Re-queried after each click: the row is re-created on every render of the + // panel, so a handle taken before the click points at a detached node. + const noteToggle = () => screen.getByRole('button', { name: new RegExp(long.slice(0, 20)) }) + await screen.findByText('Supermarket') + expect(noteToggle()).toHaveAttribute('aria-expanded', 'false') + await user.click(noteToggle()) + expect(noteToggle()).toHaveAttribute('aria-expanded', 'true') + await user.click(noteToggle()) + expect(noteToggle()).toHaveAttribute('aria-expanded', 'false') + }) + + it('never renders a receipt blob as a note', async () => { + const item = { + ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Market' }), + total_price: 20, + // How the receipt was stored before it got its own column. + note: 'TICKETJSON:{"items":[{"name":"Cheese","price":"20","parts":[1]}]}', + payers: [{ user_id: 1, amount: 20, username: 'alice' }], + } + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + ) + render() + + await screen.findByText('Market') + expect(screen.queryByText(/TICKETJSON/)).toBeNull() + }) + + it('keeps an existing note when the expense is saved again', async () => { + const item = { + ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Lunch' }), + total_price: 30, + note: 'split with the neighbours', + payers: [{ user_id: 1, amount: 30, username: 'alice' }], + } + let patched: Record | null = null + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + http.put('/api/trips/1/budget/:id', async ({ request }) => { + patched = (await request.json()) as Record + return HttpResponse.json({ item: { ...item, ...patched } }) + }), + ) + const user = userEvent.setup() + render() + + await screen.findByText('Lunch') + await user.click(screen.getAllByTitle('Edit')[0]) + await user.click(await screen.findByRole('button', { name: 'Save' })) + + await waitFor(() => expect(patched).toBeTruthy()) + expect(patched!.note).toBe('split with the neighbours') + }) + it('shows the net hint on a settled expense row and hides it while unfinished', async () => { seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true }) seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) @@ -534,7 +607,9 @@ describe('CostsPanel — settlements in the ledger', () => { expect.objectContaining({ user_id: 1, amount: 75 }), expect.objectContaining({ user_id: 2, amount: 25 }), ])) - expect(posted!.note).toContain('TICKETJSON:') + // The receipt has its own column since #1658; `note` is the user's text. + expect(JSON.parse(posted!.ticket_json as string).items).toHaveLength(3) + expect(posted!.note).toBeNull() }) // ── Display currency ─────────────────────────────────────────────────────── diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index d4440824e..4c10528cd 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { useSearchParams } from 'react-router' -import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download } from 'lucide-react' +import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react' import { useTripStore } from '../../store/tripStore' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' @@ -16,7 +16,7 @@ import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants' -import { calculateTicketShares, payersBalanced, rebalancePayers, splitEqualShares, type TicketItem } from './CostsPanel.helpers' +import { calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import type { BudgetItem } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' @@ -80,6 +80,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) + // One note open at a time: two expanded rows next to each other read as a mess, + // and the point of the collapse is that the list stays scannable. + const [expandedNoteId, setExpandedNoteId] = useState(null) const [editingSettlement, setEditingSettlement] = useState(null) const [addingPayment, setAddingPayment] = useState(false) @@ -246,8 +249,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || '')) for (const e of items) { const cur = curOf(e) - // Ticket notes carry the itemized-receipt JSON, not a human note. - const note = e.note && !e.note.startsWith('TICKETJSON:') ? e.note : '' + const note = readUserNote(e) rows.push([ esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)), (e.total_price || 0).toFixed(currencyDecimals(cur)), cur, @@ -502,6 +504,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps .costs-root .text-content-muted { color: var(--c-ink2) !important; } .costs-root .text-content-faint { color: var(--c-ink3) !important; } .costs-root .exp-actions { opacity: 1; } + .costs-root .exp-actions button { background: none; } + .costs-root .exp-actions button:hover { background: var(--bg-secondary); color: var(--c-ink); } + /* Destructive actions keep their own tint: the neutral one would read as + "safe" on the button that deletes the expense. */ + .costs-root .exp-actions .exp-action-danger:hover { background: rgba(220,38,38,0.14); color: #dc2626; } @media (max-width: 1100px) { .costs-root .costs-summary { grid-template-columns: 1fr 1fr !important; } .costs-root .costs-grid { grid-template-columns: 1fr !important; } @@ -653,6 +660,28 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } // ── inline subcomponents (close over helpers) ──────────────────────────── + /** + * Edit and delete, in a capsule beside the row rather than inside it — the + * mobile card has always done it this way, and out here the buttons stop + * competing with the amount for the right edge of the card. + */ + function RowActions({ onEdit, onDelete, deleteLabel }: { onEdit: () => void; onDelete: () => void; deleteLabel: string }) { + // No `background` here on purpose: an inline value would beat the :hover + // rule in the stylesheet block below, which is how the first attempt at + // this silently did nothing. + const btn: React.CSSProperties = { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + width: 30, height: 30, borderRadius: 999, border: 0, + cursor: 'pointer', padding: 0, + } + return ( +
+ + +
+ ) + } + function ExpenseRow({ e }: { e: BudgetItem }) { const c = catMeta(e.category) const Icon = c.Icon @@ -660,17 +689,38 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const payers = (e.payers || []).filter(p => p.amount > 0) const net = round2(myPaidOf(e) - myShareOf(e)) const unfinished = isUnfinished(e) + const note = readUserNote(e) + const open = expandedNoteId === e.id + + // Fixed columns, identical on every row, so the eye can run down the list + // instead of re-finding each field on each line. A row without a note leaves + // its column empty rather than letting everything after it drift left, which + // is what made the list read as unstructured. + const cols = isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto' + return ( -
+
+
+ {/* The category rides the card edge as a tab, the way the mobile card + does, so it stops taking up a slot inside the row itself. */} + {!isMobile && ( + + )} + {isMobile && unfinished && ( ! )} + + {/* What it was. */}
-
- {e.name} +
+ {e.name} {unfinished && !isMobile && ( ! @@ -678,8 +728,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps )}
+ {cur !== base && ( +
+ {fmt(e.total_price, cur)} {'→'} {fmt(baseTotal(e))} +
+ )} + {/* Under the name, because a chip reading "Y 122,00" says nothing on + its own — the name above it is what gives it a meaning. */} {payers.length > 0 && ( -
+
{payers.map(p => ( @@ -688,29 +745,38 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps ))}
)} - {!isMobile && ( -
- {t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''} -
- )}
+ + {/* The note, marked by a rule in the category colour instead of a box, so + it reads as part of the row rather than something dropped onto it. */} + {!isMobile && ( + note ? ( + + ) : + )} + + {/* The money, and what to do with it. */}
-
-
{fmt(baseTotal(e))}
+
+ {fmt(baseTotal(e))} {!unfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && ( -
0 ? '#16a34a' : '#dc2626' }}> + 0 ? 'rgba(22,163,74,0.13)' : 'rgba(220,38,38,0.13)'), fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, color: net > 0 ? '#16a34a' : '#dc2626' }}> {net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })} -
+ )}
- {canEdit && ( -
- - -
- )}
+ {canEdit && { setEditing(e); setModalOpen(true) }} onDelete={() => handleDelete(e.id)} deleteLabel={t('common.delete')} />} +
) } @@ -720,7 +786,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // Legacy transfers carry no currency and were entered in the display base. const cur = (s.currency || base).toUpperCase() return ( -
+
+
@@ -732,16 +799,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {personName(s.from_user_id)} → {personName(s.to_user_id)}
+ {/* A payment carries no note, but it keeps the column so its amount lands + on the same axis as every expense above it. */} + {!isMobile && }
-
{fmt(convert(s.amount, cur))}
- {canEdit && ( -
- - -
- )} + {fmt(convert(s.amount, cur))}
+ {canEdit && ( +
+ + +
+ )} +
) } @@ -936,6 +1007,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo }) { const { t, locale } = useTranslation() const toast = useToast() + const isMobile = useIsMobile() const { addBudgetItem, updateBudgetItem } = useTripStore() const { convert } = useExchangeRates(base) const sym = (c: string) => SYMBOLS[c] || (c + ' ') @@ -944,6 +1016,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const [cat, setCat] = useState(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10)) + const [note, setNote] = useState(() => readUserNote(editing)) const [total, setTotal] = useState(() => { if (editing) return editing.total_price ? String(editing.total_price) : '' if (prefill?.amount != null) return String(prefill.amount) @@ -977,7 +1050,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const [pinnedPayers, setPinnedPayers] = useState>(() => new Set(initialPayers.map(p => p.user_id))) const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => { - if (editing?.note && editing.note.startsWith('TICKETJSON:')) { + if (hasTicketSplit(editing)) { return 'ticket' } if (editing && editing.members && editing.members.length > 0) { @@ -987,22 +1060,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo return 'equally' }) - const [ticketItems, setTicketItems] = useState(() => { - if (editing?.note && editing.note.startsWith('TICKETJSON:')) { - try { - const parsed = JSON.parse(editing.note.slice(11)) - return (parsed.items || []).map((item: any) => ({ - id: String(Math.random()), - name: item.name, - price: String(item.price), - participants: new Set(item.parts || []) - })) - } catch { - return [] - } - } - return [] - }) + const [ticketItems, setTicketItems] = useState(() => readTicketItems(editing)) const [customAmounts, setCustomAmounts] = useState>(() => { const m: Record = {} @@ -1191,13 +1249,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo member_ids: [...participants], expense_date: day || null, total_price: totalNum, - note: splitMode === 'ticket' ? 'TICKETJSON:' + JSON.stringify({ - items: ticketItems.map(item => ({ - name: item.name, - price: item.price, - parts: [...item.participants] - })) - }) : null, + note: note.trim() || null, + ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null, ...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}), } try { @@ -1212,17 +1265,25 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo } const inputCls = 'w-full bg-surface-input border border-edge text-content' - const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]' + const labelCls = 'block text-caption font-semibold uppercase tracking-[0.14em] text-content-faint mb-2' + // Borrowed from the trip dialog (TripFormModal): related fields sit together in + // a panel instead of running as one undifferentiated column of inputs. + const panelCls = 'rounded-2xl border border-edge bg-surface-secondary p-4' return ( -
}> -
+ {/* Two columns on desktop: what the expense was on the left, how it is + shared on the right. The split follows the two questions the dialog + actually asks, so neither side is a leftovers pile. */} +
+
+
setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none' }} /> @@ -1260,7 +1321,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
)} -
+
+ +
{COST_CATEGORY_LIST.map(c => { @@ -1277,8 +1340,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
-
-
+
+ +
+
+
-
+
- -
+ +
@@ -1491,6 +1557,14 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo )}
+ +
+ +