diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 6942e697..7038db2d 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -1708,4 +1708,49 @@ describe('DayPlanSidebar', () => { expect(onEditTransport).toHaveBeenCalledWith(res) expect(onEditReservation).not.toHaveBeenCalled() }) + + // ── showRouteToolsWhenExpanded (mobile route tools) ─────────────────────── + + it('FE-PLANNER-DAYPLAN-099: showRouteToolsWhenExpanded shows route tools on expanded day without selection', () => { + const places = [ + buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }), + buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }), + ] + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assigns = { + '10': [ + buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }), + buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }), + ], + } + render() + // Days are expanded by default, so route tools must be visible even with no selected day + expect(screen.getByRole('button', { name: /optimize/i })).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-100: optimize via showRouteToolsWhenExpanded reorders the expanded day', async () => { + const user = userEvent.setup() + const onReorder = vi.fn().mockResolvedValue(undefined) + const places = [ + buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }), + buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }), + buildPlace({ id: 3, name: 'C', lat: 48.87, lng: 2.37 }), + ] + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assigns = { + '10': [ + buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }), + buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }), + buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }), + ], + } + render() + const optimizeBtn = screen.getByRole('button', { name: /optimize/i }) + await user.click(optimizeBtn) + await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array))) + }) }) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 41f54f60..0e7659b1 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -84,6 +84,8 @@ interface DayPlanSidebarProps { onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void initialScrollTop?: number onScrollTopChange?: (top: number) => void + /** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */ + showRouteToolsWhenExpanded?: boolean } /** @@ -125,6 +127,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { onAddBookingToAssignment, initialScrollTop, onScrollTopChange, + showRouteToolsWhenExpanded = false, } = props const toast = useToast() const { t, language, locale } = useTranslation() @@ -742,9 +745,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) }) } - const handleOptimize = async () => { - if (!selectedDayId) return - const da = getDayAssignments(selectedDayId) + const handleOptimize = async (dayId: number | null = selectedDayId) => { + if (!dayId) return + const da = getDayAssignments(dayId) if (da.length < 3) return const prevIds = da.map(a => a.id) @@ -764,7 +767,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng) // Anchor the route on the day's accommodation (when enabled): a loop out from and back to the // hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at. - const day = days.find(d => d.id === selectedDayId) + const day = days.find(d => d.id === dayId) const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false ? getAccommodationAnchors(day, days, accommodations) : {} @@ -781,10 +784,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { if (!result[i]) result[i] = optimizedQueue[qi++] } - await onReorder(selectedDayId, result.map(a => a.id)) + await onReorder(dayId, result.map(a => a.id)) const usedHotel = !!(anchors.start || anchors.end) toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized')) - const capturedDayId = selectedDayId + const capturedDayId = dayId pushUndo?.(t('undo.optimize'), async () => { await tripActions.reorderAssignments(tripId, capturedDayId, prevIds) }) @@ -901,6 +904,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { onAddBookingToAssignment, initialScrollTop, onScrollTopChange, + showRouteToolsWhenExpanded, toast, t, language, @@ -1047,6 +1051,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP onAddBookingToAssignment, initialScrollTop, onScrollTopChange, + showRouteToolsWhenExpanded, toast, t, language, @@ -2096,7 +2101,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} - {isSelected && getDayAssignments(day.id).length >= 2 && ( + {(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && (
-
- {routeInfo && ( + {isSelected && routeInfo && (
{routeInfo.distance} · diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 0bdd5c72..a84d9c83 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -616,7 +616,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} 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 }} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} 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 }} showRouteToolsWhenExpanded /> : { 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/wiki/Development-environment.md b/wiki/Development-environment.md index 66b6a684..781919e2 100644 --- a/wiki/Development-environment.md +++ b/wiki/Development-environment.md @@ -58,6 +58,7 @@ git rebase upstream/dev # or: git merge upstream/dev Working on a dedicated branch keeps your changes isolated and makes PRs easier to review: ```bash +# Create a new branch off of dev git checkout -b fix/my-changes origin/dev ``` @@ -70,16 +71,10 @@ Branch naming conventions: ## 5. Install Dependencies -Install dependencies for both the client and server: +The repo is an npm workspace monorepo. One command at the root installs everything: ```bash -# Client -cd client -npm i - -# Server -cd ../server -npm i +npm ci ``` --- @@ -127,31 +122,70 @@ You can override `KITINERARY_EXTRACTOR_PATH` if you installed the binary to a di ## 7. Available Scripts +### Root (`/`) + +These commands run across all workspaces at once and are the recommended way to work: + +| Command | Description | +|----------------------|---------------------------------------------------------------------| +| `npm run dev` | Build shared, then start shared (watch), server, and client together via `concurrently` | +| `npm run build` | Build shared → server → client in order | +| `npm test` | Run tests in shared, server, and client | +| `npm run test:cov` | Run coverage for server and client | +| `npm run test:e2e` | Run end-to-end tests (server) | +| `npm run lint` | Lint shared, server, and client | +| `npm run format` | Format shared, server, and client | +| `npm run format:check` | Check formatting across all workspaces | + +### Shared (`/shared`) + +The `@trek/shared` package is the single source of truth for code shared between the client and server. It holds the **Zod schemas that define the API contracts** (request/response shapes, common primitives, pagination) and the **i18n translation layer** (per-language keys and types). Both workspaces import from it, so schema and translation changes propagate to both sides from one place. + +> **Tip:** run `npm run i18n:parity` (or `i18n:parity:strict`) in this package to verify every locale exposes the same translation keys — the CI parity gate runs the strict variant. + +| Command | Description | +|-----------------------------|--------------------------------------| +| `npm run build` | Compile shared package (tsup) | +| `npm run build:watch` | Compile in watch mode | +| `npm test` | Run tests | +| `npm run typecheck` | Type-check without emitting | +| `npm run i18n:parity` | Check locale key parity | +| `npm run i18n:parity:strict`| Strict locale key parity (CI gate) | +| `npm run lint` | Lint source | +| `npm run format` | Format source | + ### Server (`/server`) | Command | Description | |----------------------------|------------------------------------------| | `npm start` | Start the server (production) | -| `npm run dev` | Start the server in watch mode (tsx) | +| `npm run dev` | Start the server in watch mode | +| `npm run build` | Compile server | +| `npm run typecheck` | Type-check without emitting | | `npm test` | Run all tests | | `npm run test:unit` | Run unit tests only | | `npm run test:integration` | Run integration tests | | `npm run test:ws` | Run WebSocket tests | +| `npm run test:e2e` | Run end-to-end tests | | `npm run test:watch` | Run tests in watch mode | | `npm run test:coverage` | Run tests with coverage report | +| `npm run lint` | Lint source | +| `npm run format` | Format source | ### Client (`/client`) -| Command | Description | -|--------------------------|------------------------------------------------------| -| `npm run dev` | Start the Vite dev server | -| `npm run build` | Build for production (runs icon generation first) | -| `npm run preview` | Preview the production build locally | -| `npm test` | Run all tests | -| `npm run test:unit` | Run unit tests only | -| `npm run test:integration` | Run integration tests | -| `npm run test:watch` | Run tests in watch mode | -| `npm run test:coverage` | Run tests with coverage report | +| Command | Description | +|----------------------------|------------------------------------------------------| +| `npm run dev` | Start the Vite dev server | +| `npm run build` | Build for production (runs icon generation first) | +| `npm run preview` | Preview the production build locally | +| `npm test` | Run all tests | +| `npm run test:unit` | Run unit tests only | +| `npm run test:integration` | Run integration tests | +| `npm run test:watch` | Run tests in watch mode | +| `npm run test:coverage` | Run tests with coverage report | +| `npm run lint` | Lint source | +| `npm run format` | Format source | --- @@ -162,7 +196,7 @@ git add . git commit -m "fix: describe your change" # Push to your fork's dev branch -git push origin fix/my-changes:dev +git push origin fix/my-changes # Or if working directly on dev git push origin dev @@ -175,5 +209,5 @@ Then open a Pull Request from your fork to `mauriceboe/TREK` targeting the `dev` ## Tips - Always branch off from an up-to-date `dev` — run `git fetch upstream && git rebase upstream/dev` before starting new work. -- Run tests before pushing: `npm run test` in both `client/` and `server/`. +- Run tests before pushing: `npm test` at the repo root runs all workspaces. - Follow the commit message conventions described in the [[Contributing]] guidelines. \ No newline at end of file