From cd634093af93b3207b3c8ffff0d5c3ec119ca5d9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 30 Mar 2026 13:52:35 +0200 Subject: [PATCH] feat: multi-select category filter, performance fixes, check-in/out order - Category filter is now a multi-select dropdown with checkboxes - PlaceAvatar: replace 200ms polling intervals with event-based notification + React.memo for major performance improvement - Map photo fetches: concurrency limited to 3 + lazy loading on images - PlacesSidebar: content-visibility + useMemo for smooth scrolling - Accommodation labels: check-out now appears before check-in on same day - Timed places auto-sort chronologically when time is added --- client/src/components/Map/MapView.tsx | 55 +++++---- .../src/components/Planner/DayPlanSidebar.tsx | 12 ++ .../src/components/Planner/PlacesSidebar.tsx | 105 ++++++++++++++---- client/src/components/shared/PlaceAvatar.tsx | 46 +++++--- client/src/i18n/translations/de.ts | 2 + client/src/i18n/translations/en.ts | 2 + 6 files changed, 160 insertions(+), 62 deletions(-) diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 85b4458..26e744a 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -65,7 +65,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) { cursor:pointer;flex-shrink:0;position:relative; ">
- +
${badgeHtml} `, @@ -360,33 +360,48 @@ export function MapView({ }, [leftWidth, rightWidth, hasInspector]) const [photoUrls, setPhotoUrls] = useState({}) - // Fetch photos for places (Google or Wikimedia Commons fallback) + // Fetch photos for places with concurrency limit to avoid blocking map rendering useEffect(() => { - places.forEach(place => { - if (place.image_url) return + const queue = places.filter(place => { + if (place.image_url) return false const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` - if (!cacheKey) return + if (!cacheKey) return false if (mapPhotoCache.has(cacheKey)) { const cached = mapPhotoCache.get(cacheKey) if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached })) - return + return false } - if (mapPhotoInFlight.has(cacheKey)) return + if (mapPhotoInFlight.has(cacheKey)) return false const photoId = place.google_place_id || place.osm_id - if (!photoId && !(place.lat && place.lng)) return - mapPhotoInFlight.add(cacheKey) - mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) - .then(data => { - if (data.photoUrl) { - mapPhotoCache.set(cacheKey, data.photoUrl) - setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl })) - } else { - mapPhotoCache.set(cacheKey, null) - } - mapPhotoInFlight.delete(cacheKey) - }) - .catch(() => { mapPhotoCache.set(cacheKey, null); mapPhotoInFlight.delete(cacheKey) }) + if (!photoId && !(place.lat && place.lng)) return false + return true }) + + let active = 0 + const MAX_CONCURRENT = 3 + let idx = 0 + + const fetchNext = () => { + while (active < MAX_CONCURRENT && idx < queue.length) { + const place = queue[idx++] + const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` + const photoId = place.google_place_id || place.osm_id + mapPhotoInFlight.add(cacheKey) + active++ + mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) + .then(data => { + if (data.photoUrl) { + mapPhotoCache.set(cacheKey, data.photoUrl) + setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl })) + } else { + mapPhotoCache.set(cacheKey, null) + } + }) + .catch(() => { mapPhotoCache.set(cacheKey, null) }) + .finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() }) + } + } + fetchNext() }, [places]) return ( diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 19ce046..32fdd2b 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -793,6 +793,18 @@ export default function DayPlanSidebar({ {(() => { const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id) + // Sort: check-out first, then ongoing stays, then check-in last + .sort((a, b) => { + const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id + const bIsOut = b.end_day_id === day.id && b.start_day_id !== day.id + const aIsIn = a.start_day_id === day.id + const bIsIn = b.start_day_id === day.id + if (aIsOut && !bIsOut) return -1 + if (!aIsOut && bIsOut) return 1 + if (aIsIn && !bIsIn) return 1 + if (!aIsIn && bIsIn) return -1 + return 0 + }) if (dayAccs.length === 0) return null return dayAccs.map(acc => { const isCheckIn = acc.start_day_id === day.id diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 3bef079..d49c58e 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -1,7 +1,8 @@ +import React from 'react' import ReactDOM from 'react-dom' -import { useState, useRef } from 'react' +import { useState, useRef, useMemo, useCallback } from 'react' import DOM from 'react-dom' -import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload } from 'lucide-react' +import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import { getCategoryIcon } from '../shared/categoryIcons' import { useTranslation } from '../../i18n' @@ -53,26 +54,32 @@ export default function PlacesSidebar({ } const [search, setSearch] = useState('') const [filter, setFilter] = useState('all') - const [categoryFilter, setCategoryFilterLocal] = useState('') + const [categoryFilters, setCategoryFiltersLocal] = useState>(new Set()) - const setCategoryFilter = (val: string) => { - setCategoryFilterLocal(val) - onCategoryFilterChange?.(val) + const toggleCategoryFilter = (catId: string) => { + setCategoryFiltersLocal(prev => { + const next = new Set(prev) + if (next.has(catId)) next.delete(catId); else next.add(catId) + // Notify parent with first selected or empty + onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '') + return next + }) } const [dayPickerPlace, setDayPickerPlace] = useState(null) + const [catDropOpen, setCatDropOpen] = useState(false) // Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen) const plannedIds = new Set( Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean)) ) - const filtered = places.filter(p => { + const filtered = useMemo(() => places.filter(p => { if (filter === 'unplanned' && plannedIds.has(p.id)) return false - if (categoryFilter && String(p.category_id) !== String(categoryFilter)) return false + if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && !(p.address || '').toLowerCase().includes(search.toLowerCase())) return false return true - }) + }), [places, filter, categoryFilters, search, plannedIds.size]) const isAssignedToSelectedDay = (placeId) => selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId) @@ -139,21 +146,69 @@ export default function PlacesSidebar({ )} - {/* Kategoriefilter */} - {categories.length > 0 && ( -
- ({ value: String(c.id), label: c.name })) - ]} - /> -
- )} + {/* Category multi-select dropdown */} + {categories.length > 0 && (() => { + const label = categoryFilters.size === 0 + ? t('places.allCategories') + : categoryFilters.size === 1 + ? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories') + : `${categoryFilters.size} ${t('places.categoriesSelected')}` + return ( +
+ + {catDropOpen && ( +
+ {categories.map(c => { + const active = categoryFilters.has(String(c.id)) + const CatIcon = getCategoryIcon(c.icon) + return ( + + ) + })} + {categoryFilters.size > 0 && ( + + )} +
+ )} +
+ ) + })()} {/* Anzahl */} @@ -211,6 +266,8 @@ export default function PlacesSidebar({ background: isSelected ? 'var(--border-faint)' : 'transparent', borderBottom: '1px solid var(--border-faint)', transition: 'background 0.1s', + contentVisibility: 'auto', + containIntrinsicSize: '0 52px', }} onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }} diff --git a/client/src/components/shared/PlaceAvatar.tsx b/client/src/components/shared/PlaceAvatar.tsx index c250160..027e411 100644 --- a/client/src/components/shared/PlaceAvatar.tsx +++ b/client/src/components/shared/PlaceAvatar.tsx @@ -16,8 +16,18 @@ interface PlaceAvatarProps { const photoCache = new Map() const photoInFlight = new Set() +// Event-based notification instead of polling intervals +const photoListeners = new Map void>>() -export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) { +function notifyListeners(key: string, url: string | null) { + const listeners = photoListeners.get(key) + if (listeners) { + listeners.forEach(fn => fn(url)) + photoListeners.delete(key) + } +} + +export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) { const [photoSrc, setPhotoSrc] = useState(place.image_url || null) useEffect(() => { @@ -33,28 +43,27 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP } if (photoInFlight.has(cacheKey)) { - // Another instance is already fetching, wait for it - const check = setInterval(() => { - if (photoCache.has(cacheKey)) { - clearInterval(check) - const cached = photoCache.get(cacheKey) - if (cached) setPhotoSrc(cached) - } - }, 200) - return () => clearInterval(check) + // Subscribe to notification instead of polling + if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set()) + const handler = (url: string | null) => { if (url) setPhotoSrc(url) } + photoListeners.get(cacheKey)!.add(handler) + return () => { photoListeners.get(cacheKey)?.delete(handler) } } + photoInFlight.add(cacheKey) mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) .then((data: { photoUrl?: string }) => { - if (data.photoUrl) { - photoCache.set(cacheKey, data.photoUrl) - setPhotoSrc(data.photoUrl) - } else { - photoCache.set(cacheKey, null) - } + const url = data.photoUrl || null + photoCache.set(cacheKey, url) + if (url) setPhotoSrc(url) + notifyListeners(cacheKey, url) + photoInFlight.delete(cacheKey) + }) + .catch(() => { + photoCache.set(cacheKey, null) + notifyListeners(cacheKey, null) photoInFlight.delete(cacheKey) }) - .catch(() => { photoCache.set(cacheKey, null); photoInFlight.delete(cacheKey) }) }, [place.id, place.image_url, place.google_place_id, place.osm_id]) const bgColor = category?.color || '#6366f1' @@ -76,6 +85,7 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP {place.name} setPhotoSrc(null)} /> @@ -88,4 +98,4 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP ) -} +}) diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 1915460..06fbec0 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -647,6 +647,8 @@ const de: Record = { 'places.unplanned': 'Ungeplant', 'places.search': 'Orte suchen...', 'places.allCategories': 'Alle Kategorien', + 'places.categoriesSelected': 'Kategorien', + 'places.clearFilter': 'Filter zurücksetzen', 'places.count': '{count} Orte', 'places.countSingular': '1 Ort', 'places.allPlanned': 'Alle Orte sind eingeplant', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 46e7a37..b34fe41 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -647,6 +647,8 @@ const en: Record = { 'places.unplanned': 'Unplanned', 'places.search': 'Search places...', 'places.allCategories': 'All Categories', + 'places.categoriesSelected': 'categories', + 'places.clearFilter': 'Clear filter', 'places.count': '{count} places', 'places.countSingular': '1 place', 'places.allPlanned': 'All places are planned',