From af7dbce105be44a90bbabebf06e4813ff4909acb Mon Sep 17 00:00:00 2001
From: elmocito <42518790+elmocito@users.noreply.github.com>
Date: Mon, 5 Oct 2026 23:53:11 +0200
Subject: [PATCH] fix(tours): prevent duplicate day assignments
---
.../components/Tours/ToursSidebar.test.tsx | 30 +++++
client/src/components/Tours/ToursSidebar.tsx | 2 +-
.../Tours/planner/TourPlannerPanels.tsx | 2 +
client/src/components/Tours/tourParts.tsx | 19 ++-
.../components/shared/ContextMenu.test.tsx | 9 ++
client/src/components/shared/ContextMenu.tsx | 9 +-
.../trip/sheets/MBrowseActionsSheet.tsx | 19 ++-
.../pages/tripPlanner/useTripPlanner.test.tsx | 31 +++++
.../src/pages/tripPlanner/useTripPlanner.ts | 1 +
.../trip/MBrowseActionsSheet.tours.test.tsx | 109 ++++++++++++++++++
.../nest/assignments/assignments.service.ts | 11 +-
server/tests/e2e/assignments.e2e.test.ts | 32 +++++
.../unit/nest/assignments.controller.test.ts | 14 ++-
.../unit/nest/assignments.service.test.ts | 40 +++++++
14 files changed, 313 insertions(+), 15 deletions(-)
create mode 100644 client/tests/unit/mobile/trip/MBrowseActionsSheet.tours.test.tsx
diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx
index 0879cbf7e..cad5e72f9 100644
--- a/client/src/components/Tours/ToursSidebar.test.tsx
+++ b/client/src/components/Tours/ToursSidebar.test.tsx
@@ -129,6 +129,36 @@ describe('ToursSidebar', () => {
expect(onToursChanged).not.toHaveBeenCalled()
})
+ it('disables already-assigned days and refreshes availability when the picker is reopened', async () => {
+ const dayOne = { id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null } as never
+ const dayTwo = { id: 8, trip_id: 1, day_number: 2, title: 'Day two', date: null } as never
+ const dayThree = { id: 9, trip_id: 1, day_number: 3, title: 'Day three', date: null } as never
+ useTripStore.setState({ assignments: { '7': [{ id: 1, day_id: 7, place_id: tour.place_id }] } as never })
+ const onAssignToDay = vi.fn((placeId: number, dayId: number) => {
+ useTripStore.setState(state => ({
+ assignments: { ...state.assignments, [String(dayId)]: [{ id: 2, day_id: dayId, place_id: placeId }] } as never,
+ }))
+ return true
+ })
+ render()
+
+ const openPicker = () => fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
+ openPicker()
+ expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /Day two/ })).toBeEnabled()
+ fireEvent.click(screen.getByRole('button', { name: /Day two/ }))
+ expect(onAssignToDay).toHaveBeenCalledTimes(1)
+ expect(onAssignToDay).toHaveBeenCalledWith(tour.place_id, 8)
+
+ openPicker()
+ expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /Day two/ })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /Day three/ })).toBeEnabled()
+ fireEvent.click(screen.getByRole('button', { name: /Day three/ }))
+ expect(onAssignToDay).toHaveBeenCalledTimes(2)
+ expect(onAssignToDay).toHaveBeenLastCalledWith(tour.place_id, 9)
+ })
+
it('neither refreshes nor reports success when assignment fails', async () => {
toast.success.mockClear()
const onToursChanged = vi.fn()
diff --git a/client/src/components/Tours/ToursSidebar.tsx b/client/src/components/Tours/ToursSidebar.tsx
index 4157efc02..f61b1a907 100644
--- a/client/src/components/Tours/ToursSidebar.tsx
+++ b/client/src/components/Tours/ToursSidebar.tsx
@@ -178,7 +178,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
tour={tour}
selected={tour.place_id === selectedPlaceId}
onSelect={(selectedTour, opener) => onSelectTour?.(selectedTour, opener)}
- action={ { void handleAssign(tour, day, index) }} />}
+ action={ { void handleAssign(tour, day, index) }} />}
/>
))}
diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx
index 8f1cd1c78..82c0dde17 100644
--- a/client/src/components/Tours/planner/TourPlannerPanels.tsx
+++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx
@@ -471,6 +471,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
variant="button"
primary
days={days}
+ placeId={planner.saveOutcome.place_id}
label={t('tours.planner.assignToDay')}
disabled={!canAssign || assigning}
onPick={day => { void assignSavedTour(day.id) }}
@@ -498,6 +499,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
action={canAssign && days.length > 0 ? (
{ if (canAssign) void onAssignToDay(tour.place_id, day.id) }}
/>
diff --git a/client/src/components/Tours/tourParts.tsx b/client/src/components/Tours/tourParts.tsx
index 27376056c..2449f8eab 100644
--- a/client/src/components/Tours/tourParts.tsx
+++ b/client/src/components/Tours/tourParts.tsx
@@ -10,6 +10,7 @@ import { formatDate } from '../../utils/formatters'
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
import { DialogButton, fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
+import { useTripStore } from '../../store/tripStore'
import { BOX, Eyebrow } from '../Planner/bookings/bookingParts'
import { SoftPill } from '../Planner/planParts'
import { hikeSourceBadgeLabel } from './tourPresentation'
@@ -73,8 +74,9 @@ function dayMenuLabel(day: Day, index: number, t: Translate, locale: string): st
* Putting a tour on a day: a round "+" on a row, or a footer button with a
* chevron, both opening the trip's days as a menu.
*/
-export function TourDayMenu({ days, onPick, disabled = false, label, variant = 'round', primary = false }: {
+export function TourDayMenu({ days, placeId, onPick, disabled = false, label, variant = 'round', primary = false }: {
days: Day[]
+ placeId?: number
onPick: (day: Day, index: number) => void
disabled?: boolean
label: string
@@ -83,9 +85,16 @@ export function TourDayMenu({ days, onPick, disabled = false, label, variant = '
}) {
const { t, locale } = useTranslation()
const menu = useContextMenu()
- const items = days.map((day, index) => ({ label: dayMenuLabel(day, index, t, locale), onClick: () => onPick(day, index) }))
+ const assignments = useTripStore(state => state.assignments ?? {})
+ const assigned = (dayId: number) => placeId != null && (assignments[String(dayId)] ?? []).some(assignment => assignment.place_id === placeId)
+ const items = days.map((day, index) => ({
+ label: dayMenuLabel(day, index, t, locale),
+ disabled: assigned(day.id),
+ onClick: () => onPick(day, index),
+ }))
+ const hasAvailableDay = items.some(item => !item.disabled)
const open = (event: React.MouseEvent) => {
- if (disabled || items.length === 0) return
+ if (disabled || !hasAvailableDay) return
if (menu.menu) { event.stopPropagation(); menu.close(); return }
menu.open(event, items, true)
}
@@ -93,13 +102,13 @@ export function TourDayMenu({ days, onPick, disabled = false, label, variant = '
<>
{variant === 'round' ? (
-
) : (
- }>
{label}
diff --git a/client/src/components/shared/ContextMenu.test.tsx b/client/src/components/shared/ContextMenu.test.tsx
index 5f00397f6..4838ac09a 100644
--- a/client/src/components/shared/ContextMenu.test.tsx
+++ b/client/src/components/shared/ContextMenu.test.tsx
@@ -79,4 +79,13 @@ describe('ContextMenu', () => {
});
expect(onClose).toHaveBeenCalledOnce();
});
+
+ it('does not activate a disabled menu item', () => {
+ const onClick = vi.fn()
+ render()
+ const item = screen.getByRole('button', { name: 'Already assigned' })
+ expect(item).toBeDisabled()
+ fireEvent.click(item)
+ expect(onClick).not.toHaveBeenCalled()
+ })
});
diff --git a/client/src/components/shared/ContextMenu.tsx b/client/src/components/shared/ContextMenu.tsx
index 3558a9398..6cdb6a22f 100644
--- a/client/src/components/shared/ContextMenu.tsx
+++ b/client/src/components/shared/ContextMenu.tsx
@@ -6,6 +6,7 @@ interface MenuItem {
label?: string
icon?: LucideIcon
onClick?: () => void
+ disabled?: boolean
danger?: boolean
divider?: boolean
}
@@ -84,15 +85,15 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
if (item.divider) return
const Icon = item.icon
return (
-