From 404981505c5d16122f7a9562563616b00819eca3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 31 May 2026 18:29:23 +0200 Subject: [PATCH] Resolve the remaining client type errors and the trip.title navbar bug Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change. --- client/src/components/Admin/BackupPanel.tsx | 4 +-- .../Admin/DefaultUserSettingsTab.tsx | 1 - client/src/components/Admin/GitHubPanel.tsx | 6 ++++ .../Admin/PackingTemplateManager.test.tsx | 3 +- client/src/components/Budget/BudgetPanel.tsx | 6 ++-- client/src/components/Collab/CollabChat.tsx | 8 ++--- client/src/components/Collab/CollabNotes.tsx | 6 ++-- .../components/Collab/CollabPolls.test.tsx | 2 +- client/src/components/Collab/CollabPolls.tsx | 6 ++-- .../src/components/Collab/WhatsNextWidget.tsx | 1 + .../src/components/Files/FileManager.test.tsx | 4 +-- client/src/components/Files/FileManager.tsx | 8 ++--- client/src/components/Layout/Navbar.tsx | 2 +- client/src/components/Map/MapView.tsx | 15 ++++---- client/src/components/Map/MapViewGL.tsx | 4 ++- client/src/components/PDF/JourneyBookPDF.tsx | 2 +- client/src/components/PDF/TripPDF.tsx | 16 +++++---- .../components/Packing/PackingListPanel.tsx | 4 +-- .../Planner/DayPlanSidebar.test.tsx | 4 +-- .../src/components/Planner/DayPlanSidebar.tsx | 28 +++++++-------- .../Planner/PlaceFormModal.test.tsx | 4 +-- .../src/components/Planner/PlaceFormModal.tsx | 35 ++++++++++++------- .../Planner/PlaceInspector.test.tsx | 2 +- .../src/components/Planner/PlaceInspector.tsx | 8 ++--- .../src/components/Planner/PlacesSidebar.tsx | 4 +-- .../Planner/ReservationModal.test.tsx | 4 +-- .../components/Planner/ReservationModal.tsx | 10 +++--- .../src/components/Planner/TransportModal.tsx | 6 ++-- .../components/Settings/MapSettingsTab.tsx | 1 - client/src/components/Settings/OfflineTab.tsx | 2 +- client/src/components/Todo/TodoListPanel.tsx | 8 +++-- client/src/components/Todo/useTodoList.ts | 4 ++- client/src/components/Trips/TripFormModal.tsx | 9 ++--- .../src/components/Trips/TripMembersModal.tsx | 2 +- client/src/components/Vacay/VacayPersons.tsx | 2 +- client/src/components/Vacay/VacaySettings.tsx | 8 ++--- .../components/Weather/WeatherWidget.test.tsx | 2 +- client/src/components/shared/Tooltip.tsx | 26 +++++++++----- client/src/hooks/usePlaceSelection.ts | 2 +- client/src/pages/AdminPage.tsx | 4 +-- client/src/pages/AtlasPage.test.tsx | 2 +- client/src/pages/DashboardPage.tsx | 4 +-- client/src/pages/FilesPage.test.tsx | 4 +-- client/src/pages/FilesPage.tsx | 4 +-- client/src/pages/TripPlannerPage.test.tsx | 8 ++--- client/src/pages/TripPlannerPage.tsx | 6 ++-- client/src/pages/VacayPage.test.tsx | 6 ++-- client/src/pages/VacayPage.tsx | 6 ++-- client/src/pages/atlas/useAtlas.ts | 4 +-- client/src/pages/dashboard/dashboardModel.ts | 22 ++++-------- client/src/pages/dashboard/useDashboard.ts | 5 +-- client/src/pages/files/useFiles.ts | 3 +- .../src/pages/tripPlanner/useTripPlanner.ts | 30 +++++++++------- client/src/repo/packingRepo.ts | 2 +- client/src/repo/placeRepo.ts | 2 +- client/src/store/inAppNotificationStore.ts | 2 +- client/src/store/slices/assignmentsSlice.ts | 1 + client/src/store/slices/budgetSlice.ts | 2 +- client/src/store/slices/dayNotesSlice.ts | 2 +- client/src/store/slices/packingSlice.ts | 4 +-- client/src/store/slices/placesSlice.ts | 4 +-- client/src/store/slices/remoteEventHandler.ts | 7 ++-- client/src/store/slices/reservationsSlice.ts | 2 +- client/src/store/slices/todoSlice.ts | 5 +-- client/src/store/tripStore.ts | 8 ++--- client/src/types.ts | 17 --------- client/tests/helpers/msw/handlers/budget.ts | 2 +- client/tests/helpers/render.tsx | 4 +-- .../integration/hooks/useDayNotes.test.ts | 5 +-- .../hooks/useRouteCalculation.test.ts | 5 +++ 70 files changed, 241 insertions(+), 210 deletions(-) diff --git a/client/src/components/Admin/BackupPanel.tsx b/client/src/components/Admin/BackupPanel.tsx index 44acdf5f..a049f8b9 100644 --- a/client/src/components/Admin/BackupPanel.tsx +++ b/client/src/components/Admin/BackupPanel.tsx @@ -360,7 +360,7 @@ export default function BackupPanel() { handleAutoSettingsChange('hour', parseInt(v, 10))} + onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))} size="sm" options={HOURS.map(h => { let label: string @@ -408,7 +408,7 @@ export default function BackupPanel() { handleAutoSettingsChange('day_of_month', parseInt(v, 10))} + onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))} size="sm" options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))} /> diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index 13d57d8f..59b934d4 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -130,7 +130,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement { lng: 2.3522, address: null, category_id: null, - icon: null, price: null, currency: null, image_url: null, diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index 325cb5ee..a0238929 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -9,6 +9,12 @@ const PER_PAGE = 10 interface GithubRelease { id: number prerelease: boolean + tag_name: string + name: string | null + body: string | null + published_at: string | null + created_at: string + author: { login: string } | null [key: string]: unknown } diff --git a/client/src/components/Admin/PackingTemplateManager.test.tsx b/client/src/components/Admin/PackingTemplateManager.test.tsx index 74b2986e..dedfc90f 100644 --- a/client/src/components/Admin/PackingTemplateManager.test.tsx +++ b/client/src/components/Admin/PackingTemplateManager.test.tsx @@ -500,7 +500,8 @@ describe('PackingTemplateManager', () => { // Find the X (cancel) button in the create row — it's the last button in the create row const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!; - const cancelBtn = Array.from(createRow.querySelectorAll('button')).at(-1) as HTMLElement; + const createRowButtons = Array.from(createRow.querySelectorAll('button')); + const cancelBtn = createRowButtons[createRowButtons.length - 1] as HTMLElement; await user.click(cancelBtn); await waitFor(() => diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index 82a82f0a..aa13e82c 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -71,7 +71,7 @@ function hexLighten(hex: string, amount: number): string { import CustomSelect from '../shared/CustomSelect' import { budgetApi } from '../../api/client' import { CustomDatePicker } from '../shared/CustomDateTimePicker' -import type { BudgetItem, BudgetMember } from '../../types' +import type { BudgetItem, BudgetItemMember } from '../../types' import { currencyDecimals } from '../../utils/formatters' interface TripMember { @@ -124,7 +124,7 @@ const calcPD = (p, d) => (d > 0 ? p / d : null) const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null) // ── Inline Edit Cell ───────────────────────────────────────────────────────── -function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) { +function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) { const [editing, setEditing] = useState(false) const [editValue, setEditValue] = useState(value ?? '') const inputRef = useRef(null) @@ -314,7 +314,7 @@ function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWit // ── Budget Member Chips (for Persons column) ──────────────────────────────── interface BudgetMemberChipsProps { - members?: BudgetMember[] + members?: BudgetItemMember[] tripMembers?: TripMember[] onSetMembers: (memberIds: number[]) => void onTogglePaid?: (userId: number, paid: boolean) => void diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index a5c7e2d4..8ee70371 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -275,7 +275,7 @@ function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) { > {data.image && ( e.target.style.display = 'none'} /> + onError={e => e.currentTarget.style.display = 'none'} /> )}
{domain && ( @@ -561,7 +561,7 @@ function useCollabChat(tripId: any, currentUser: any) { if (!body || sending) return setSending(true) try { - const payload = { text: body } + const payload: { text: string; reply_to?: number } = { text: body } if (replyTo) payload.reply_to = replyTo.id const data = await collabApi.sendMessage(tripId, payload) if (data?.message) { @@ -739,13 +739,13 @@ function ChatMessages(props: any) { onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }} onTouchEnd={e => { const now = Date.now() - const lastTap = e.currentTarget.dataset.lastTap || 0 + const lastTap = Number(e.currentTarget.dataset.lastTap) || 0 if (now - lastTap < 300 && canEdit) { e.preventDefault() const touch = e.changedTouches?.[0] if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY }) } - e.currentTarget.dataset.lastTap = now + e.currentTarget.dataset.lastTap = String(now) }} > {bigEmoji ? ( diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index f8bd652e..eafeccfe 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -589,7 +589,7 @@ interface CategorySettingsModalProps { function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }: CategorySettingsModalProps) { const [localColors, setLocalColors] = useState({ ...categoryColors }) - const [renames, setRenames] = useState({}) // { oldName: newName } + const [renames, setRenames] = useState>({}) // { oldName: newName } const [newCatName, setNewCatName] = useState('') const handleColorChange = (cat, color) => { @@ -814,8 +814,8 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
{/* Author avatar */}
{ const tip = e.currentTarget.querySelector('[data-tip]'); if (tip) tip.style.opacity = '1' }} - onMouseLeave={e => { const tip = e.currentTarget.querySelector('[data-tip]'); if (tip) tip.style.opacity = '0' }}> + onMouseEnter={e => { const tip = e.currentTarget.querySelector('[data-tip]'); if (tip) tip.style.opacity = '1' }} + onMouseLeave={e => { const tip = e.currentTarget.querySelector('[data-tip]'); if (tip) tip.style.opacity = '0' }}>
{ ), ); seedStore(useAuthStore, { user: currentUser, isAuthenticated: true }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) }); }); describe('CollabPolls', () => { diff --git a/client/src/components/Collab/CollabPolls.tsx b/client/src/components/Collab/CollabPolls.tsx index 79528518..ad09e084 100644 --- a/client/src/components/Collab/CollabPolls.tsx +++ b/client/src/components/Collab/CollabPolls.tsx @@ -79,7 +79,7 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) { if (!canSubmit) return setSubmitting(true) try { - await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multiple_choice: multiChoice }) + await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multi_choice: multiChoice }) onClose() } catch {} finally { setSubmitting(false) } } @@ -231,7 +231,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: {remaining} )} - {poll.multiple_choice && ( + {poll.multi_choice && ( {t('collab.polls.multiChoice')} @@ -306,7 +306,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400, color: 'var(--text-primary)', position: 'relative', zIndex: 1, }}> - {typeof opt === 'string' ? opt : opt.label || opt} + {typeof opt === 'string' ? opt : opt.text} {/* Voter avatars */} diff --git a/client/src/components/Collab/WhatsNextWidget.tsx b/client/src/components/Collab/WhatsNextWidget.tsx index 90d39caf..4c23233b 100644 --- a/client/src/components/Collab/WhatsNextWidget.tsx +++ b/client/src/components/Collab/WhatsNextWidget.tsx @@ -30,6 +30,7 @@ function formatDayLabel(date, t, locale) { interface TripMember { id: number username: string + avatar?: string | null avatar_url?: string | null } diff --git a/client/src/components/Files/FileManager.test.tsx b/client/src/components/Files/FileManager.test.tsx index 4db7b40c..323589e4 100644 --- a/client/src/components/Files/FileManager.test.tsx +++ b/client/src/components/Files/FileManager.test.tsx @@ -322,8 +322,8 @@ describe('FileManager', () => { it('FE-COMP-FILEMANAGER-018: starred filter shows only starred files', async () => { const files = [ - buildFile({ id: 1, original_name: 'starred.pdf', starred: true }), - buildFile({ id: 2, original_name: 'normal.pdf', starred: false }), + buildFile({ id: 1, original_name: 'starred.pdf', starred: 1 }), + buildFile({ id: 2, original_name: 'normal.pdf', starred: 0 }), ]; render(); const user = userEvent.setup(); diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 00297ffd..bbf71f96 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -249,13 +249,13 @@ interface FileManagerProps { files?: TripFile[] onUpload: (fd: FormData) => Promise onDelete: (fileId: number) => Promise - onUpdate: (fileId: number, data: Partial) => Promise + onUpdate?: (fileId: number, data: Partial) => Promise places: Place[] days?: Day[] assignments?: AssignmentsMap reservations?: Reservation[] tripId: number - allowedFileTypes: Record + allowedFileTypes?: string | null } /** @@ -368,11 +368,11 @@ function useFileManager({ files = [], onUpload, onDelete, onUpdate, places, days noClick: false, }) - const handlePaste = useCallback((e) => { + const handlePaste = useCallback((e: React.ClipboardEvent) => { if (!can('file_upload', trip)) return const items = e.clipboardData?.items if (!items) return - const pastedFiles = [] + const pastedFiles: File[] = [] for (const item of Array.from(items)) { if (item.kind === 'file') { const file = item.getAsFile() diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index d6897aae..0f2dc59a 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -13,7 +13,7 @@ const ADDON_ICONS: Record = { CalendarDays, Briefcase, Globe interface NavbarProps { tripTitle?: string - tripId?: string + tripId?: number | string onBack?: () => void showBack?: boolean onShare?: () => void diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index affc2da8..368f4c67 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -19,8 +19,9 @@ function categoryIconSvg(iconName: string | null | undefined, size: number): str } import type { Place } from '../../types' -// Fix default marker icons for vite -delete L.Icon.Default.prototype._getIconUrl +// Fix default marker icons for vite. `_getIconUrl` is a Leaflet-internal field +// not present in the public typings, so narrow to delete it. +delete (L.Icon.Default.prototype as { _getIconUrl?: unknown })._getIconUrl L.Icon.Default.mergeOptions({ iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png', iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png', @@ -121,7 +122,7 @@ interface SelectionControllerProps { places: Place[] selectedPlaceId: number | null dayPlaces: Place[] - paddingOpts: Record + paddingOpts: L.FitBoundsOptions } function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) { @@ -166,7 +167,7 @@ interface BoundsControllerProps { hasDayDetail?: boolean places: Place[] fitKey: number - paddingOpts: Record + paddingOpts: L.FitBoundsOptions } function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) { @@ -210,7 +211,7 @@ function MapClickHandler({ onClick }: MapClickHandlerProps) { useEffect(() => { if (!onClick) return map.on('click', onClick) - return () => map.off('click', onClick) + return () => { map.off('click', onClick) } }, [map, onClick]) return null } @@ -220,7 +221,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle useEffect(() => { if (!onContextMenu) return map.on('contextmenu', onContextMenu) - return () => map.off('contextmenu', onContextMenu) + return () => { map.off('contextmenu', onContextMenu) } }, [map, onContextMenu]) return null } @@ -362,7 +363,7 @@ export const MapView = memo(function MapView({ return reservations.filter((r: Reservation) => set.has(r.id)) }, [reservations, visibleConnectionIds]) // Dynamic padding: account for sidebars + bottom inspector + day detail panel - const paddingOpts = useMemo(() => { + const paddingOpts = useMemo((): L.FitBoundsOptions => { const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 if (isMobile) return { padding: [40, 20] } const top = 60 diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 0d9d1c6f..73f50f1c 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -313,7 +313,9 @@ export function MapViewGL({ // eslint-disable-next-line @typescript-eslint/no-explicit-any const curAlt = (ll as any).alt ?? 0 if (Math.abs(curAlt - alt) > 0.25) { - marker.setLngLat([ll.lng, ll.lat, alt]) + // mapbox-gl accepts a third altitude element at runtime, but its typings + // only model the 2-tuple form, so cast to LngLatLike. + marker.setLngLat([ll.lng, ll.lat, alt] as unknown as mapboxgl.LngLatLike) } }) } diff --git a/client/src/components/PDF/JourneyBookPDF.tsx b/client/src/components/PDF/JourneyBookPDF.tsx index 97f30388..21fa9699 100644 --- a/client/src/components/PDF/JourneyBookPDF.tsx +++ b/client/src/components/PDF/JourneyBookPDF.tsx @@ -73,7 +73,7 @@ function renderPhotoBlock(photos: JourneyPhoto[]): string { } export async function downloadJourneyBookPDF(journey: JourneyDetail) { - const entries = (journey.entries || []).filter(e => e.type !== 'skeleton' && e.type !== 'gallery') + const entries = (journey.entries || []).filter(e => e.type !== 'skeleton') const allPhotos = entries.flatMap(e => e.photos || []) const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '') diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index dc4016e9..cf586150 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -93,17 +93,19 @@ function dayCost(assignments, dayId, locale) { } // Pre-fetch Google Place photos for all assigned places -async function fetchPlacePhotos(assignments) { +async function fetchPlacePhotos(assignments: AssignmentsMap) { const photoMap = {} // placeId → photoUrl const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean) const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()] - const toFetch = unique.filter(p => !p.image_url && (p.google_place_id || p.osm_id)) + // Assignment places are a server-side projection that omits osm_id, so photo + // pre-fetch keys off the google_place_id that the projection does carry. + const toFetch = unique.filter(p => !p.image_url && p.google_place_id) await Promise.allSettled( toFetch.map(async (place) => { try { - const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name) + const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name) if (data.photoUrl) photoMap[place.id] = data.photoUrl } catch {} }) @@ -141,7 +143,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean) ).size const totalCost = Object.values(assignments || {}) - .flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0) + .flatMap(a => a).reduce((s, a) => s + (Number(a.place?.price) || 0), 0) // Span helpers for multi-day transport (mirrors DayPlanSidebar logic) const pdfGetDayOrder = (d: Day) => d.day_number @@ -575,6 +577,8 @@ ${daysHtml} overlay.appendChild(card) document.body.appendChild(overlay) - header.querySelector('#pdf-close-btn').onclick = () => overlay.remove() - header.querySelector('#pdf-print-btn').onclick = () => { iframe.contentWindow?.print() } + const closeBtn = header.querySelector('#pdf-close-btn') + if (closeBtn) closeBtn.onclick = () => overlay.remove() + const printBtn = header.querySelector('#pdf-print-btn') + if (printBtn) printBtn.onclick = () => { iframe.contentWindow?.print() } } diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index a02c2a93..f3a43036 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -732,10 +732,10 @@ interface MenuItemProps { icon: React.ReactNode label: string onClick: () => void - danger: boolean + danger?: boolean } -function MenuItem({ icon, label, onClick, danger }: MenuItemProps) { +function MenuItem({ icon, label, onClick, danger = false }: MenuItemProps) { return (
{t('dashboard.fx.to')}
- + setTo(String(v))} options={ccyOptions} searchable size="sm" style={{ marginTop: 6 }} />
diff --git a/client/src/pages/FilesPage.test.tsx b/client/src/pages/FilesPage.test.tsx index 22298b8c..da8f25fa 100644 --- a/client/src/pages/FilesPage.test.tsx +++ b/client/src/pages/FilesPage.test.tsx @@ -60,7 +60,7 @@ describe('FilesPage', () => { describe('FE-PAGE-FILES-002: Trip name displayed in Navbar after load', () => { it('passes the trip name to Navbar after data loads', async () => { - const trip = buildTrip({ id: 1, name: 'Rome Trip' }); + const trip = buildTrip({ id: 1, title: 'Rome Trip' }); server.use( http.get('/api/trips/:id', () => HttpResponse.json({ trip })), ); @@ -130,7 +130,7 @@ describe('FilesPage', () => { renderFilesPage(1); await waitFor(() => { - expect(mockLoadFiles).toHaveBeenCalledWith('1'); + expect(mockLoadFiles).toHaveBeenCalledWith(1); }); }); }); diff --git a/client/src/pages/FilesPage.tsx b/client/src/pages/FilesPage.tsx index e7a880c0..352164f6 100644 --- a/client/src/pages/FilesPage.tsx +++ b/client/src/pages/FilesPage.tsx @@ -22,7 +22,7 @@ export default function FilesPage(): React.ReactElement { } return ( - navigate(`/trips/${tripId}`) }}> + navigate(`/trips/${tripId}`) }}>

{t('files.pageTitle')}

-

{t('files.subtitle', { count: files.length, trip: trip?.name })}

+

{t('files.subtitle', { count: files.length, trip: trip?.title })}

diff --git a/client/src/pages/TripPlannerPage.test.tsx b/client/src/pages/TripPlannerPage.test.tsx index 3ea43503..ba332f5b 100644 --- a/client/src/pages/TripPlannerPage.test.tsx +++ b/client/src/pages/TripPlannerPage.test.tsx @@ -252,7 +252,7 @@ describe('TripPlannerPage', () => { renderPlannerPage(42); await waitFor(() => { - expect(mockLoadTrip).toHaveBeenCalledWith('42'); + expect(mockLoadTrip).toHaveBeenCalledWith(42); }); }); }); @@ -298,7 +298,7 @@ describe('TripPlannerPage', () => { renderPlannerPage(999); await waitFor(() => { - expect(mockLoadTrip).toHaveBeenCalledWith('999'); + expect(mockLoadTrip).toHaveBeenCalledWith(999); }); }); }); @@ -359,13 +359,13 @@ describe('TripPlannerPage', () => { }); describe('FE-PAGE-PLANNER-008: WebSocket hook mounted', () => { - it('calls useTripWebSocket with the trip ID string', async () => { + it('calls useTripWebSocket with the trip ID from URL params', async () => { seedTripStore({ id: 15 }); renderPlannerPage(15); await waitFor(() => { - expect(mockUseTripWebSocket).toHaveBeenCalledWith('15'); + expect(mockUseTripWebSocket).toHaveBeenCalledWith(15); }); }); }); diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 03f63c6b..56c52a04 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -344,7 +344,7 @@ export default function TripPlannerPage(): React.ReactElement | null { onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} - onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }} + onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} @@ -434,6 +434,8 @@ export default function TripPlannerPage(): React.ReactElement | null { onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} + days={days} + isMobile={false} />
@@ -591,7 +593,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} /> : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> }
diff --git a/client/src/pages/VacayPage.test.tsx b/client/src/pages/VacayPage.test.tsx index a2acd672..5ba0b906 100644 --- a/client/src/pages/VacayPage.test.tsx +++ b/client/src/pages/VacayPage.test.tsx @@ -152,7 +152,7 @@ describe('VacayPage', () => { // FE-PAGE-VACAY-009 it('shows incoming invite overlay with username and action buttons', async () => { seedStore(useVacayStore, makeVacayState({ - incomingInvites: [{ id: 1, plan_id: 99, username: 'bob' }], + incomingInvites: [{ plan_id: 99, owner_username: 'bob' }], }) as any); render(); await waitFor(() => { @@ -166,7 +166,7 @@ describe('VacayPage', () => { it('calls acceptInvite with plan_id on accept button click', async () => { const mockAcceptInvite = vi.fn(); seedStore(useVacayStore, makeVacayState({ - incomingInvites: [{ id: 1, plan_id: 99, username: 'bob' }], + incomingInvites: [{ plan_id: 99, owner_username: 'bob' }], acceptInvite: mockAcceptInvite, }) as any); render(); @@ -181,7 +181,7 @@ describe('VacayPage', () => { it('calls declineInvite with plan_id on decline button click', async () => { const mockDeclineInvite = vi.fn(); seedStore(useVacayStore, makeVacayState({ - incomingInvites: [{ id: 1, plan_id: 99, username: 'bob' }], + incomingInvites: [{ plan_id: 99, owner_username: 'bob' }], declineInvite: mockDeclineInvite, }) as any); render(); diff --git a/client/src/pages/VacayPage.tsx b/client/src/pages/VacayPage.tsx index 01ac9a94..0226b81e 100644 --- a/client/src/pages/VacayPage.tsx +++ b/client/src/pages/VacayPage.tsx @@ -223,16 +223,16 @@ export default function VacayPage(): React.ReactElement {
{incomingInvites.map(inv => ( -
+
- {inv.username?.[0]?.toUpperCase()} + {inv.owner_username?.[0]?.toUpperCase()}

{t('vacay.inviteTitle')}

- {inv.username} {t('vacay.inviteWantsToFuse')} + {inv.owner_username} {t('vacay.inviteWantsToFuse')}

diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts index 774a4c24..6e5f2422 100644 --- a/client/src/pages/atlas/useAtlas.ts +++ b/client/src/pages/atlas/useAtlas.ts @@ -371,7 +371,7 @@ export function useAtlas() { } } } - }).addTo(mapInstance.current) + } as L.GeoJSONOptions & { renderer?: L.Renderer }).addTo(mapInstance.current) // Restore map view after re-render mapInstance.current.setView(currentCenter, currentZoom, { animate: false }) @@ -516,7 +516,7 @@ export function useAtlas() { if (tt) tt.style.display = 'none' }) }, - }) + } as L.GeoJSONOptions & { renderer?: L.Renderer }) // Only add to map if currently in region mode — otherwise hold it ready for when user zooms in if (mapInstance.current.getZoom() >= 6) { regionLayerRef.current.addTo(mapInstance.current) diff --git a/client/src/pages/dashboard/dashboardModel.ts b/client/src/pages/dashboard/dashboardModel.ts index 42f8cafd..4aa85cd7 100644 --- a/client/src/pages/dashboard/dashboardModel.ts +++ b/client/src/pages/dashboard/dashboardModel.ts @@ -5,21 +5,13 @@ * container + data hook" convention (see dashboard/README.md). */ -export interface DashboardTrip { - id: number - title: string - description?: string | null - start_date?: string | null - end_date?: string | null - cover_image?: string | null - is_archived?: boolean - is_owner?: boolean - owner_username?: string - day_count?: number - place_count?: number - shared_count?: number - [key: string]: string | number | boolean | null | undefined -} +import type { Trip } from '../../types' + +// The dashboard works with the canonical Trip shape returned by the list/get +// endpoints (it already carries the computed day_count/place_count/is_owner/ +// owner_username/shared_count fields). Kept as a named alias so the existing +// imports stay stable. +export type DashboardTrip = Trip export interface Member { id: number; username: string; avatar_url?: string | null } export interface Place { diff --git a/client/src/pages/dashboard/useDashboard.ts b/client/src/pages/dashboard/useDashboard.ts index 49462e06..9fbbd9af 100644 --- a/client/src/pages/dashboard/useDashboard.ts +++ b/client/src/pages/dashboard/useDashboard.ts @@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { useToast } from '../../components/shared/Toast' import { getApiErrorMessage } from '../../types' +import type { TripCreateRequest } from '@trek/shared' import { type DashboardTrip, type TravelStats, @@ -98,7 +99,7 @@ export function useDashboard() { return () => { cancelled = true } }, [spotlight?.id]) - const handleCreate = async (tripData: Record) => { + const handleCreate = async (tripData: TripCreateRequest) => { try { const data = await tripsApi.create(tripData) setTrips(prev => sortTrips([data.trip, ...prev])) @@ -109,7 +110,7 @@ export function useDashboard() { } } - const handleUpdate = async (tripData: Record) => { + const handleUpdate = async (tripData: TripCreateRequest) => { if (!editingTrip) return try { const data = await tripsApi.update(editingTrip.id, tripData) diff --git a/client/src/pages/files/useFiles.ts b/client/src/pages/files/useFiles.ts index f51d8b05..11b1abad 100644 --- a/client/src/pages/files/useFiles.ts +++ b/client/src/pages/files/useFiles.ts @@ -11,7 +11,8 @@ import type { Trip, Place, TripFile } from '../../types' * Behaviour is identical to the previous in-component logic. */ export function useFiles() { - const { id: tripId } = useParams<{ id: string }>() + const { id } = useParams<{ id: string }>() + const tripId = Number(id) const navigate = useNavigate() const tripStore = useTripStore() diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index e5387b32..50111571 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -27,7 +27,11 @@ import type { Accommodation, TripMember, Day, Place, Reservation } from '../../t * Behaviour is identical to the previous in-component logic. */ export function useTripPlanner() { - const { id: tripId } = useParams<{ id: string }>() + const { id } = useParams<{ id: string }>() + // The route param is a string; convert once here so every downstream component + // prop and store call gets a real number. An absent/invalid id becomes NaN, + // which stays falsy in the `if (tripId)` guards below. + const tripId = id ? Number(id) : NaN const navigate = useNavigate() const toast = useToast() const { t, language } = useTranslation() @@ -273,7 +277,7 @@ export function useTripPlanner() { const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile) - const handleSelectDay = useCallback((dayId, skipFit) => { + const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => { const changed = dayId !== selectedDayId tripActions.setSelectedDay(dayId) if (changed && !skipFit) setFitKey(k => k + 1) @@ -281,7 +285,7 @@ export function useTripPlanner() { updateRouteForDay(dayId) }, [updateRouteForDay, selectedDayId]) - const handlePlaceClick = useCallback((placeId, assignmentId) => { + const handlePlaceClick = useCallback((placeId: number | null, assignmentId?: number | null) => { if (assignmentId) { selectAssignment(assignmentId, placeId) } else { @@ -290,7 +294,7 @@ export function useTripPlanner() { if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) } }, [selectAssignment, setSelectedPlaceId]) - const handleMarkerClick = useCallback((placeId) => { + const handleMarkerClick = useCallback((placeId?: number) => { if (placeId === undefined) { setSelectedPlaceId(null) return @@ -303,7 +307,7 @@ export function useTripPlanner() { const matching = allAssignments.filter(a => a?.place?.id === placeId) if (matching.length === 0) { - setSelectedPlaceId(prev => prev === placeId ? null : placeId) + setSelectedPlaceId(selectedPlaceId === placeId ? null : placeId) } else if (matching.length === 1) { const only = matching[0] if (selectedAssignmentId === only.id) { @@ -323,7 +327,7 @@ export function useTripPlanner() { } } setLeftCollapsed(false); setRightCollapsed(false) - }, [selectAssignment, selectedAssignmentId, setSelectedPlaceId]) + }, [selectAssignment, selectedAssignmentId, selectedPlaceId, setSelectedPlaceId]) const handleMapClick = useCallback(() => { setSelectedPlaceId(null) @@ -363,7 +367,7 @@ export function useTripPlanner() { for (const file of pendingFiles) { const fd = new FormData() fd.append('file', file) - fd.append('place_id', editingPlace.id) + fd.append('place_id', String(editingPlace.id)) try { await tripActions.addFile(tripId, fd) } catch { toast.error(t('files.uploadError')) } } } @@ -374,7 +378,7 @@ export function useTripPlanner() { for (const file of pendingFiles) { const fd = new FormData() fd.append('file', file) - fd.append('place_id', place.id) + fd.append('place_id', String(place.id)) try { await tripActions.addFile(tripId, fd) } catch { toast.error(t('files.uploadError')) } } } @@ -454,7 +458,7 @@ export function useTripPlanner() { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo]) - const handleAssignToDay = useCallback(async (placeId, dayId, position) => { + const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => { const target = dayId || selectedDayId if (!target) { toast.error(t('trip.toast.selectDay')); return } try { @@ -471,7 +475,7 @@ export function useTripPlanner() { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo]) - const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => { + const handleRemoveAssignment = useCallback(async (dayId: number, assignmentId: number) => { const state = useTripStore.getState() const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId) const capturedPlaceId = capturedAssignment?.place?.id @@ -490,7 +494,7 @@ export function useTripPlanner() { catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }, [tripId, toast, updateRouteForDay, pushUndo]) - const handleReorder = useCallback((dayId, orderedIds) => { + const handleReorder = useCallback((dayId: number, orderedIds: number[]) => { const prevIds = (useTripStore.getState().assignments[String(dayId)] || []) .slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) try { @@ -513,7 +517,7 @@ export function useTripPlanner() { catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }, [tripId, toast]) - const handleSaveReservation = async (data) => { + const handleSaveReservation = async (data: Record & { title: string }) => { try { if (editingReservation) { const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null }) @@ -537,7 +541,7 @@ export function useTripPlanner() { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } } - const handleSaveTransport = async (data) => { + const handleSaveTransport = async (data: Record & { title: string }) => { try { if (editingTransport) { const r = await tripActions.updateReservation(tripId, editingTransport.id, data) diff --git a/client/src/repo/packingRepo.ts b/client/src/repo/packingRepo.ts index 30859fc6..42051b7b 100644 --- a/client/src/repo/packingRepo.ts +++ b/client/src/repo/packingRepo.ts @@ -17,7 +17,7 @@ export const packingRepo = { return result }, - async create(tripId: number | string, data: Record): Promise<{ item: PackingItem }> { + async create(tripId: number | string, data: Record & { name: string }): Promise<{ item: PackingItem }> { if (!navigator.onLine) { const tempId = -(Date.now()) const tempItem: PackingItem = { diff --git a/client/src/repo/placeRepo.ts b/client/src/repo/placeRepo.ts index 36b1acc2..8f4ee28a 100644 --- a/client/src/repo/placeRepo.ts +++ b/client/src/repo/placeRepo.ts @@ -17,7 +17,7 @@ export const placeRepo = { return result }, - async create(tripId: number | string, data: Record): Promise<{ place: Place }> { + async create(tripId: number | string, data: Record & { name: string }): Promise<{ place: Place }> { if (!navigator.onLine) { const tempId = -(Date.now()) const tempPlace: Place = { diff --git a/client/src/store/inAppNotificationStore.ts b/client/src/store/inAppNotificationStore.ts index 85bc75a5..70006a1c 100644 --- a/client/src/store/inAppNotificationStore.ts +++ b/client/src/store/inAppNotificationStore.ts @@ -79,7 +79,7 @@ export const useInAppNotificationStore = create((set, get) => try { const offset = reset ? 0 : notifications.length const data = await inAppNotificationsApi.list({ limit: PAGE_SIZE, offset }) - const normalized = (data.notifications as RawNotification[]).map(normalizeNotification) + const normalized = (data.notifications as unknown as RawNotification[]).map(normalizeNotification) set({ notifications: reset ? normalized : [...notifications, ...normalized], diff --git a/client/src/store/slices/assignmentsSlice.ts b/client/src/store/slices/assignmentsSlice.ts index 8d44fb50..cc551af5 100644 --- a/client/src/store/slices/assignmentsSlice.ts +++ b/client/src/store/slices/assignmentsSlice.ts @@ -27,6 +27,7 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment const tempAssignment: Assignment = { id: tempId, day_id: parseInt(String(dayId)), + place_id: place.id, order_index: insertIdx, notes: null, place, diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts index 40dd2189..577ee22e 100644 --- a/client/src/store/slices/budgetSlice.ts +++ b/client/src/store/slices/budgetSlice.ts @@ -95,7 +95,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => // Optimistic: reorder locally set(state => { const byId = new Map(state.budgetItems.map(i => [i.id, i])) - const reordered = orderedIds.map((id, idx) => { + const reordered = orderedIds.map((id, idx): BudgetItem | null => { const item = byId.get(id) return item ? { ...item, sort_order: idx } : null }).filter((i): i is BudgetItem => i !== null) diff --git a/client/src/store/slices/dayNotesSlice.ts b/client/src/store/slices/dayNotesSlice.ts index 53ab0c6d..1b1e6dd5 100644 --- a/client/src/store/slices/dayNotesSlice.ts +++ b/client/src/store/slices/dayNotesSlice.ts @@ -10,7 +10,7 @@ type GetState = StoreApi['getState'] export interface DayNotesSlice { updateDayNotes: (tripId: number | string, dayId: number | string, notes: string) => Promise updateDayTitle: (tripId: number | string, dayId: number | string, title: string) => Promise - addDayNote: (tripId: number | string, dayId: number | string, data: Partial) => Promise + addDayNote: (tripId: number | string, dayId: number | string, data: Partial & { text: string }) => Promise updateDayNote: (tripId: number | string, dayId: number | string, id: number, data: Partial) => Promise deleteDayNote: (tripId: number | string, dayId: number | string, id: number) => Promise moveDayNote: (tripId: number | string, fromDayId: number | string, toDayId: number | string, noteId: number, sort_order?: number) => Promise diff --git a/client/src/store/slices/packingSlice.ts b/client/src/store/slices/packingSlice.ts index 2bd10957..fa1704bb 100644 --- a/client/src/store/slices/packingSlice.ts +++ b/client/src/store/slices/packingSlice.ts @@ -9,7 +9,7 @@ type SetState = StoreApi['setState'] type GetState = StoreApi['getState'] export interface PackingSlice { - addPackingItem: (tripId: number | string, data: Partial) => Promise + addPackingItem: (tripId: number | string, data: Partial & { name: string }) => Promise updatePackingItem: (tripId: number | string, id: number, data: Partial) => Promise deletePackingItem: (tripId: number | string, id: number) => Promise togglePackingItem: (tripId: number | string, id: number, checked: boolean) => Promise @@ -18,7 +18,7 @@ export interface PackingSlice { export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({ addPackingItem: async (tripId, data) => { try { - const result = await packingRepo.create(tripId, data as Record) + const result = await packingRepo.create(tripId, data as Record & { name: string }) set(state => ({ packingItems: [...state.packingItems, result.item] })) return result.item } catch (err: unknown) { diff --git a/client/src/store/slices/placesSlice.ts b/client/src/store/slices/placesSlice.ts index 224a0488..6f53d85e 100644 --- a/client/src/store/slices/placesSlice.ts +++ b/client/src/store/slices/placesSlice.ts @@ -9,7 +9,7 @@ type GetState = StoreApi['getState'] export interface PlacesSlice { refreshPlaces: (tripId: number | string) => Promise - addPlace: (tripId: number | string, placeData: Partial) => Promise + addPlace: (tripId: number | string, placeData: Partial & { name: string }) => Promise updatePlace: (tripId: number | string, placeId: number, placeData: Partial) => Promise deletePlace: (tripId: number | string, placeId: number) => Promise deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise @@ -27,7 +27,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => addPlace: async (tripId, placeData) => { try { - const data = await placeRepo.create(tripId, placeData as Record) + const data = await placeRepo.create(tripId, placeData as Record & { name: string }) set(state => ({ places: [data.place, ...state.places] })) return data.place } catch (err: unknown) { diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts index d0918183..8cd3d5fd 100644 --- a/client/src/store/slices/remoteEventHandler.ts +++ b/client/src/store/slices/remoteEventHandler.ts @@ -363,7 +363,10 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket return { budgetItems: state.budgetItems.map(i => i.id === payload.itemId - ? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid } : m) } + // `paid` arrives over the wire as the raw value the server emits; + // it's stored verbatim. The member type models it as a number, so + // narrow without changing the value. + ? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid as number } : m) } : i ), } @@ -371,7 +374,7 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket if (payload.orderedIds) { const orderedIds = payload.orderedIds as number[] const byId = new Map(state.budgetItems.map(i => [i.id, i])) - const reordered = orderedIds.map((id, idx) => { + const reordered = orderedIds.map((id, idx): BudgetItem | null => { const item = byId.get(id) return item ? { ...item, sort_order: idx } : null }).filter((i): i is BudgetItem => i !== null) diff --git a/client/src/store/slices/reservationsSlice.ts b/client/src/store/slices/reservationsSlice.ts index 96414c9a..24122ef5 100644 --- a/client/src/store/slices/reservationsSlice.ts +++ b/client/src/store/slices/reservationsSlice.ts @@ -10,7 +10,7 @@ type GetState = StoreApi['getState'] export interface ReservationsSlice { loadReservations: (tripId: number | string) => Promise - addReservation: (tripId: number | string, data: Partial) => Promise + addReservation: (tripId: number | string, data: Partial & { title: string }) => Promise updateReservation: (tripId: number | string, id: number, data: Partial) => Promise toggleReservationStatus: (tripId: number | string, id: number) => Promise deleteReservation: (tripId: number | string, id: number) => Promise diff --git a/client/src/store/slices/todoSlice.ts b/client/src/store/slices/todoSlice.ts index ce545fa5..e7b1d096 100644 --- a/client/src/store/slices/todoSlice.ts +++ b/client/src/store/slices/todoSlice.ts @@ -2,6 +2,7 @@ import { todoApi } from '../../api/client' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { TodoItem } from '../../types' +import type { TodoCreateItemRequest, TodoUpdateItemRequest } from '@trek/shared' import { getApiErrorMessage } from '../../types' import { notify } from '../notify' @@ -9,8 +10,8 @@ type SetState = StoreApi['setState'] type GetState = StoreApi['getState'] export interface TodoSlice { - addTodoItem: (tripId: number | string, data: Partial) => Promise - updateTodoItem: (tripId: number | string, id: number, data: Partial) => Promise + addTodoItem: (tripId: number | string, data: TodoCreateItemRequest) => Promise + updateTodoItem: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => Promise deleteTodoItem: (tripId: number | string, id: number) => Promise toggleTodoItem: (tripId: number | string, id: number, checked: boolean) => Promise } diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index 5168c078..3756d02f 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -61,8 +61,8 @@ export interface TripStoreState loadTrip: (tripId: number | string) => Promise refreshDays: (tripId: number | string) => Promise updateTrip: (tripId: number | string, data: Partial) => Promise - addTag: (data: Partial) => Promise - addCategory: (data: Partial) => Promise + addTag: (data: Partial & { name: string }) => Promise + addCategory: (data: Partial & { name: string }) => Promise } export const useTripStore = create((set, get) => ({ @@ -162,7 +162,7 @@ export const useTripStore = create((set, get) => ({ } }, - addTag: async (data: Partial) => { + addTag: async (data: Partial & { name: string }) => { try { const result = await tagsApi.create(data) set((state) => ({ tags: [...state.tags, result.tag] })) @@ -172,7 +172,7 @@ export const useTripStore = create((set, get) => ({ } }, - addCategory: async (data: Partial) => { + addCategory: async (data: Partial & { name: string }) => { try { const result = await categoriesApi.create(data) set((state) => ({ categories: [...state.categories, result.category] })) diff --git a/client/src/types.ts b/client/src/types.ts index 89bc5991..204234f3 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -167,23 +167,6 @@ export interface UserWithOidc extends User { oidc_issuer?: string | null } -// Photo type — trip photo as consumed by the PhotosPage / PhotoGallery / -// PhotoLightbox surface (photos table joined with a served `url`). file_size is -// the photos.file_size column; url is the served upload path. -export interface Photo { - id: number - trip_id?: number - url: string - original_name: string - mime_type?: string - file_size?: number | null - caption: string | null - place_id: number | null - day_id: number | null - taken_at?: string | null - created_at: string -} - // Atlas place detail export interface AtlasPlace { id: number diff --git a/client/tests/helpers/msw/handlers/budget.ts b/client/tests/helpers/msw/handlers/budget.ts index 936e6862..7b01d37a 100644 --- a/client/tests/helpers/msw/handlers/budget.ts +++ b/client/tests/helpers/msw/handlers/budget.ts @@ -26,7 +26,7 @@ export const budgetHandlers = [ http.put('/api/trips/:id/budget/:itemId/members', async ({ params, request }) => { const body = await request.json() as { user_ids: number[] }; - const members = body.user_ids.map(uid => ({ user_id: uid, paid: false })); + const members = body.user_ids.map(uid => ({ user_id: uid, paid: 0, username: `user${uid}` })); const item = buildBudgetItem({ id: Number(params.itemId), trip_id: Number(params.id), persons: body.user_ids.length, members }); return HttpResponse.json({ members, item }); }), diff --git a/client/tests/helpers/render.tsx b/client/tests/helpers/render.tsx index 0956ff53..b62cdefb 100644 --- a/client/tests/helpers/render.tsx +++ b/client/tests/helpers/render.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { render, type RenderOptions } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, type MemoryRouterProps } from 'react-router-dom'; import { TranslationProvider } from '../../src/i18n/TranslationContext'; interface RenderWithProvidersOptions extends Omit { - initialEntries?: string[]; + initialEntries?: MemoryRouterProps['initialEntries']; } function renderWithProviders( diff --git a/client/tests/integration/hooks/useDayNotes.test.ts b/client/tests/integration/hooks/useDayNotes.test.ts index 67a87bdd..88c0cdab 100644 --- a/client/tests/integration/hooks/useDayNotes.test.ts +++ b/client/tests/integration/hooks/useDayNotes.test.ts @@ -439,9 +439,10 @@ describe('useDayNotes', () => { }); }); -// Type augment for window.__addToast +// Type augment for window.__addToast — must mirror the canonical declaration +// in components/shared/Toast.tsx (a divergent signature is a merge conflict). declare global { interface Window { - __addToast?: (message: string, type: string, duration?: number) => void; + __addToast?: (message: string, type?: 'success' | 'error' | 'warning' | 'info', duration?: number) => number; } } diff --git a/client/tests/integration/hooks/useRouteCalculation.test.ts b/client/tests/integration/hooks/useRouteCalculation.test.ts index 125b39ba..aa97af5b 100644 --- a/client/tests/integration/hooks/useRouteCalculation.test.ts +++ b/client/tests/integration/hooks/useRouteCalculation.test.ts @@ -26,10 +26,15 @@ function buildMockStore(assignments: Record