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
This commit is contained in:
Maurice
2026-03-30 13:52:35 +02:00
parent 7201380504
commit cd634093af
6 changed files with 160 additions and 62 deletions

View File

@@ -65,7 +65,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
cursor:pointer;flex-shrink:0;position:relative;
">
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
</div>
${badgeHtml}
</div>`,
@@ -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 (

View File

@@ -793,6 +793,18 @@ export default function DayPlanSidebar({
</button>
{(() => {
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

View File

@@ -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<Set<string>>(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({
)}
</div>
{/* Kategoriefilter */}
{categories.length > 0 && (
<div style={{ marginTop: 6 }}>
<CustomSelect
value={categoryFilter}
onChange={setCategoryFilter}
placeholder={t('places.allCategories')}
size="sm"
options={[
{ value: '', label: t('places.allCategories') },
...categories.map(c => ({ value: String(c.id), label: c.name }))
]}
/>
</div>
)}
{/* 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 (
<div style={{ marginTop: 6, position: 'relative' }}>
<button onClick={() => setCatDropOpen(v => !v)} style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
cursor: 'pointer', fontFamily: 'inherit',
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{catDropOpen && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, maxHeight: 200, overflowY: 'auto',
}}>
{categories.map(c => {
const active = categoryFilters.has(String(c.id))
const CatIcon = getCategoryIcon(c.icon)
return (
<button key={c.id} onClick={() => toggleCategoryFilter(String(c.id))} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: active ? 'var(--bg-hover)' : 'transparent',
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
textAlign: 'left',
}}>
<div style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: active ? 'none' : '1.5px solid var(--border-primary)',
background: active ? (c.color || 'var(--accent)') : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{active && <Check size={10} strokeWidth={3} color="white" />}
</div>
<CatIcon size={12} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
<span style={{ flex: 1 }}>{c.name}</span>
</button>
)
})}
{categoryFilters.size > 0 && (
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
marginTop: 2, borderTop: '1px solid var(--border-faint)',
}}>
<X size={10} /> {t('places.clearFilter')}
</button>
)}
</div>
)}
</div>
)
})()}
</div>
{/* 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' }}

View File

@@ -16,8 +16,18 @@ interface PlaceAvatarProps {
const photoCache = new Map<string, string | null>()
const photoInFlight = new Set<string>()
// Event-based notification instead of polling intervals
const photoListeners = new Map<string, Set<(url: string | null) => 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<string | null>(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
<img
src={photoSrc}
alt={place.name}
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)}
/>
@@ -88,4 +98,4 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
</div>
)
}
})

View File

@@ -647,6 +647,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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',

View File

@@ -647,6 +647,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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',