From e224befde7fdb5c11f2fa7524e98b25dce9b4102 Mon Sep 17 00:00:00 2001
From: Maurice <61554723+mauriceboe@users.noreply.github.com>
Date: Fri, 12 Jun 2026 20:23:34 +0200
Subject: [PATCH] Map/planner/dashboard polish and small community features
(#1155)
* feat(planner): reorder days in a modal instead of a dropdown
The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged.
* feat(map): explore reliability, Mapbox popups + compass, region-biased search
POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out.
Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north.
/api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result.
* feat(dashboard): list-view and mobile polish
Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts.
Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar.
* feat: small community-requested options
Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields.
* test(shared): bump day-note subtitle limit assertion to 250
* test: align specs with the new search param order and archive label
Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.
---
client/src/api/client.ts | 4 +-
client/src/components/Map/MapCompassPill.tsx | 48 +++++
client/src/components/Map/MapViewGL.test.tsx | 6 +
client/src/components/Map/MapViewGL.tsx | 36 +++-
client/src/components/Map/PoiCategoryPill.tsx | 22 ++-
client/src/components/Map/placePopup.ts | 68 ++++++++
client/src/components/Map/usePoiExplore.ts | 49 +++++-
client/src/components/PDF/TripPDF.tsx | 1 +
.../Planner/DayPlanSidebar.constants.ts | 13 ++
.../Planner/DayPlanSidebarNoteModal.tsx | 4 +-
.../Planner/DayPlanSidebarToolbar.tsx | 19 +-
.../components/Planner/DayReorderPopup.tsx | 164 +++++++++---------
client/src/components/Todo/TodoListPanel.tsx | 62 +++++--
client/src/index.css | 17 ++
client/src/pages/DashboardPage.test.tsx | 10 +-
client/src/pages/DashboardPage.tsx | 7 +-
client/src/pages/TripPlannerPage.tsx | 12 +-
client/src/pages/admin/AdminUserModals.tsx | 54 ++++--
client/src/styles/dashboard.css | 33 +++-
server/src/mcp/tools/trips.ts | 6 +-
server/src/nest/maps/maps.controller.ts | 7 +-
server/src/nest/maps/maps.service.ts | 4 +-
server/src/services/mapsService.ts | 149 +++++++++++++---
.../tests/unit/nest/maps.controller.test.ts | 2 +-
shared/src/day/day.schema.spec.ts | 4 +-
shared/src/day/day.schema.ts | 4 +-
shared/src/i18n/de/dashboard.ts | 2 +-
shared/src/i18n/en/dashboard.ts | 2 +-
28 files changed, 623 insertions(+), 186 deletions(-)
create mode 100644 client/src/components/Map/MapCompassPill.tsx
create mode 100644 client/src/components/Map/placePopup.ts
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 5fe940ea..f74fa9d8 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -559,8 +559,10 @@ export const mapsApi = {
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
// OSM-only POI explore: places of a category within the current map viewport bbox.
+ // Overpass can be slow on a fresh (uncached) area, so this call gets a longer
+ // timeout than the global default instead of aborting at 8s and showing nothing.
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
- apiClient.get('/maps/pois', { params: { category, ...bbox }, signal }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean }),
+ apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
}
export const airportsApi = {
diff --git a/client/src/components/Map/MapCompassPill.tsx b/client/src/components/Map/MapCompassPill.tsx
new file mode 100644
index 00000000..65dc3289
--- /dev/null
+++ b/client/src/components/Map/MapCompassPill.tsx
@@ -0,0 +1,48 @@
+import { useEffect, useState } from 'react'
+import { Navigation } from 'lucide-react'
+import type mapboxgl from 'mapbox-gl'
+
+/**
+ * Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
+ * pitched, so this shows the current bearing (the arrow points to north) and snaps
+ * the camera back to north + flat on click. Rendered next to the POI "explore" pill
+ * (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
+ * so its height and transparency match the POI pill exactly.
+ */
+export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
+ const [bearing, setBearing] = useState(() => map.getBearing())
+
+ useEffect(() => {
+ const update = () => setBearing(map.getBearing())
+ update()
+ map.on('rotate', update)
+ return () => { map.off('rotate', update) }
+ }, [map])
+
+ return (
+
+
+
+ )
+}
diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx
index 5a305a70..5cd83aa6 100644
--- a/client/src/components/Map/MapViewGL.test.tsx
+++ b/client/src/components/Map/MapViewGL.test.tsx
@@ -40,6 +40,12 @@ vi.mock('mapbox-gl', () => ({
})),
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
NavigationControl: vi.fn(),
+ Popup: vi.fn(() => ({
+ setLngLat: vi.fn().mockReturnThis(),
+ setHTML: vi.fn().mockReturnThis(),
+ addTo: vi.fn().mockReturnThis(),
+ remove: vi.fn(),
+ })),
},
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx
index ac7b593a..8c6ece11 100644
--- a/client/src/components/Map/MapViewGL.tsx
+++ b/client/src/components/Map/MapViewGL.tsx
@@ -13,6 +13,7 @@ import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
+import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -53,6 +54,7 @@ interface Props {
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
+ onMapReady?: (map: mapboxgl.Map | null) => void
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -167,6 +169,7 @@ export function MapViewGL({
pois = [],
onPoiClick,
onViewportChange,
+ onMapReady,
}: Props) {
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
@@ -186,10 +189,15 @@ export function MapViewGL({
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
const poiMarkersRef = useRef([])
+ // Single reusable hover popup (name/category/address card) shared by planned
+ // places and POI markers — mirrors the Leaflet map's hover tooltip.
+ const popupRef = useRef(null)
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
+ const onMapReadyRef = useRef(onMapReady)
+ onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
@@ -212,6 +220,16 @@ export function MapViewGL({
projection: mapboxQuality ? 'globe' : 'mercator',
})
mapRef.current = map
+ popupRef.current = new mapboxgl.Popup({
+ closeButton: false,
+ closeOnClick: false,
+ offset: 18,
+ maxWidth: '240px',
+ className: 'trek-map-popup',
+ })
+ // Hand the map out so the trip planner can render its own compass pill next to
+ // the POI pill (a custom round control instead of Mapbox's default top-right one).
+ onMapReadyRef.current?.(map)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).__trek_map = map
@@ -357,6 +375,8 @@ export function MapViewGL({
canvas.removeEventListener('auxclick', onAuxClick)
markersRef.current.forEach(m => m.remove())
markersRef.current.clear()
+ if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
+ onMapReadyRef.current?.(null)
if (reservationOverlayRef.current) {
reservationOverlayRef.current.destroy()
reservationOverlayRef.current = null
@@ -430,6 +450,10 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map) return
+ // Markers are about to be rebuilt; drop any open hover popup first. A marker
+ // recreated under the pointer (e.g. when its photo streams in) never fires
+ // mouseleave, which would otherwise leave the popup orphaned on the map.
+ popupRef.current?.remove()
const ids = new Set(places.map(p => p.id))
markersRef.current.forEach((marker, id) => {
@@ -450,6 +474,12 @@ export function MapViewGL({
ev.stopPropagation()
onClickRefs.current.marker?.(place.id)
})
+ el.addEventListener('mouseenter', () => {
+ popupRef.current?.setLngLat([place.lng, place.lat])
+ .setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
+ .addTo(map)
+ })
+ el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
// Recreate marker each time rather than patching internal state —
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
const existing = markersRef.current.get(place.id)
@@ -471,11 +501,15 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
+ popupRef.current?.remove() // same orphan-popup guard as the place markers
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category)
- el.title = poi.name
+ el.addEventListener('mouseenter', () => {
+ popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
+ })
+ el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
poiMarkersRef.current.push(m)
diff --git a/client/src/components/Map/PoiCategoryPill.tsx b/client/src/components/Map/PoiCategoryPill.tsx
index f68fbd81..2eaf4c0e 100644
--- a/client/src/components/Map/PoiCategoryPill.tsx
+++ b/client/src/components/Map/PoiCategoryPill.tsx
@@ -1,4 +1,4 @@
-import { RotateCw } from 'lucide-react'
+import { RotateCw, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { POI_CATEGORIES } from './poiCategories'
@@ -7,6 +7,8 @@ interface Props {
active: Set
onToggle: (key: string) => void
loadingKeys?: Set
+ /** categories whose last fetch failed → show a retry affordance */
+ errorKeys?: Set
/** true when the map moved since the last search → offer "search this area" */
moved?: boolean
onSearchArea?: () => void
@@ -15,8 +17,9 @@ interface Props {
// Frosted, icon-only segmented control that floats over the map. Active segments
// fill with the category colour (matching their markers); the label shows in a
// custom tooltip on hover so the pill stays compact and never needs to scroll.
-export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved, onSearchArea }: Props) {
+export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
const { t } = useTranslation()
+ const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
const frosted: React.CSSProperties = {
background: 'var(--sidebar-bg)',
@@ -40,6 +43,7 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
aria-label={t(cat.labelKey)}
className={on ? '' : 'text-content-muted'}
style={{
+ position: 'relative',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: on ? cat.color : 'transparent',
@@ -61,13 +65,19 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
) : (
)}
+ {on && !loading && errorKeys?.has(cat.key) && (
+
+ )}
)
})}
- {moved && active.size > 0 && (
+ {(moved || anyError) && active.size > 0 && (
)}
diff --git a/client/src/components/Map/placePopup.ts b/client/src/components/Map/placePopup.ts
new file mode 100644
index 00000000..ae79577e
--- /dev/null
+++ b/client/src/components/Map/placePopup.ts
@@ -0,0 +1,68 @@
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
+import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
+import type { Place } from '../../types'
+
+// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
+// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
+// no equivalent, so these produce the same card as an HTML string for a
+// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.
+
+type PlaceWithCategory = Place & {
+ category_color?: string | null
+ category_icon?: string | null
+ category_name?: string | null
+}
+
+function esc(s: string | null | undefined): string {
+ if (!s) return ''
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
+
+// Render a lucide category icon to an inline SVG string in the given colour.
+function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
+ const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
+ try {
+ return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
+ } catch {
+ return ''
+ }
+}
+
+// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
+// into an — everything else is a fetch seed, not a displayable URL.
+function isDisplayablePhoto(url: string | null | undefined): url is string {
+ return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
+}
+
+const CARD_OPEN = '