perf: major trip planner performance overhaul (#218)

Store & re-render optimization:
- TripPlannerPage uses selective Zustand selectors instead of full store
- placesSlice only updates affected days on place update/delete
- Route calculation only reacts to selected day's assignments
- DayPlanSidebar uses stable action refs instead of full store

Map marker performance:
- Shared photoService for PlaceAvatar and MapView (single cache, no duplicate requests)
- Client-side base64 thumbnail generation via canvas (CORS-safe for Wikimedia)
- Map markers use base64 data URL <img> tags for smooth zoom (no external image decode)
- Sidebar uses same base64 thumbnails with IntersectionObserver for visible-first loading
- Icon cache prevents duplicate L.divIcon creation
- MarkerClusterGroup with animate:false and optimized chunk settings
- Photo fetch deduplication and batched state updates

Server optimizations:
- Wikimedia image size reduced to 400px (from 600px)
- Photo cache: 5min TTL for errors (was 12h), prevents stale 404 caching
- Removed unused image-proxy endpoint

UX improvements:
- Splash screen with plane animation during initial photo preload
- Markdown rendering in DayPlanSidebar place descriptions
- Missing i18n keys added, all 12 languages synced to 1376 keys
This commit is contained in:
Maurice
2026-04-01 14:56:01 +02:00
parent 7d0ae631b8
commit 95cb81b0e5
20 changed files with 456 additions and 212 deletions
@@ -96,7 +96,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const { t, language, locale } = useTranslation()
const ctxMenu = useContextMenu()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const tripStore = useTripStore()
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canEditDays = can('day_edit', trip)
@@ -425,7 +425,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
try {
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
for (const n of noteUpdates) {
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
}
if (transportUpdates.length) {
for (const tu of transportUpdates) {
@@ -518,7 +518,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
currentAssignments[key] = currentAssignments[key].map(a =>
a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a
)
tripStore.setAssignments(currentAssignments)
tripActions.setAssignments(currentAssignments)
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Unknown error')
@@ -653,9 +653,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (assignmentId && fromDayId !== dayId) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId && fromDayId !== dayId) {
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
}
setDraggingId(null)
setDropTargetKey(null)
@@ -911,11 +911,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
} else if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (assignmentId) {
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId)
} else if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId)
}
@@ -929,11 +929,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
setDropTargetKey(null); window.__dragData = null; return
}
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
@@ -1028,7 +1028,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
setDropTargetKey(null); window.__dragData = null
} else if (fromAssignmentId && fromDayId !== day.id) {
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
@@ -1036,7 +1036,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
@@ -1121,10 +1121,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
)}
</div>
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
<div className="collab-note-md" style={{ marginTop: 2, fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2, maxHeight: '1.2em' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
</div>
)}
{(() => {
@@ -1217,11 +1215,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
} else if (fromAssignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id)
} else if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id)
}
@@ -1290,7 +1288,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null)
} else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
@@ -1298,7 +1296,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
@@ -1363,11 +1361,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
}
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
@@ -1618,7 +1616,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{/* Dateien */}
{(() => {
const resFiles = (tripStore.files || []).filter(f =>
const resFiles = (useTripStore.getState().files || []).filter(f =>
!f.deleted_at && (
f.reservation_id === res.id ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id))