From 2f6f921aeffd23bc3c1c34e2353e71dc2c42586e Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 23 Sep 2026 18:42:02 +0200 Subject: [PATCH] feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier. The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle. On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens. --- client/src/api/client.ts | 7 +- .../src/components/Planner/DayPlanSidebar.tsx | 7 +- .../Planner/DayPlanSidebarToolbar.test.tsx | 12 +- .../Planner/DayPlanSidebarToolbar.tsx | 5 +- .../Planner/DayReorderPopup.test.tsx | 46 +- .../components/Planner/DayReorderPopup.tsx | 48 +- .../components/shared/ConfirmDialog.test.tsx | 39 ++ .../src/components/shared/ConfirmDialog.tsx | 32 +- .../components/shared/DayImpactList.test.tsx | 49 ++ .../src/components/shared/DayImpactList.tsx | 66 +++ .../src/mobile/components/MDayImpactList.tsx | 30 ++ .../mobile/screens/trip/sheets/MDaysSheet.tsx | 42 +- .../screens/trip/sheets/MTripSheets.tsx | 17 + client/src/pages/TripPlannerPage.test.tsx | 55 ++- client/src/pages/TripPlannerPage.tsx | 17 +- client/src/pages/tripPlanner/useDayDelete.ts | 99 ++++ .../pages/tripPlanner/useTripPlanner.test.tsx | 108 ++++- .../src/pages/tripPlanner/useTripPlanner.ts | 21 +- client/src/repo/dayRepo.test.ts | 35 +- client/src/repo/dayRepo.ts | 20 +- client/src/store/slices/daysSlice.test.ts | 74 ++- client/src/store/slices/daysSlice.ts | 48 ++ client/src/store/slices/remoteEventHandler.ts | 20 +- client/src/utils/dayDeleteImpact.test.ts | 124 +++++ client/src/utils/dayDeleteImpact.ts | 136 ++++++ client/src/utils/dayImpactLines.test.ts | 56 +++ client/src/utils/dayImpactLines.ts | 97 ++++ client/src/utils/dayLabel.test.ts | 22 + client/src/utils/dayLabel.ts | 23 + client/tests/helpers/mobileTrip.ts | 11 +- .../unit/mobile/trip/MDaysSheet.test.tsx | 25 +- .../unit/mobile/trip/MTripSheets.test.tsx | 31 +- .../unit/remoteEventHandler/days.test.ts | 41 ++ server/src/nest/days/day-removal.service.ts | 233 ++++++++++ server/src/nest/days/days.controller.ts | 22 +- server/src/nest/days/days.mcp.ts | 21 +- server/src/nest/days/days.module.ts | 15 +- server/src/nest/days/days.rpc.ts | 14 +- server/src/nest/days/days.service.ts | 10 +- server/src/nest/trips/trip-select.ts | 39 ++ server/src/nest/trips/trips.service.ts | 34 +- server/tests/e2e/days.e2e.test.ts | 35 +- server/tests/helpers/mcp-test-controllers.ts | 5 +- server/tests/helpers/plugin-host.ts | 3 +- server/tests/helpers/rpc-host-deps.ts | 2 +- server/tests/helpers/test-db.ts | 1 + server/tests/integration/days.test.ts | 71 ++- .../unit/days/days-itinerary.rpc.test.ts | 53 ++- .../mcp/tools-days-accommodations.test.ts | 129 +++++- .../unit/nest/day-removal.service.test.ts | 427 ++++++++++++++++++ .../tests/unit/nest/days.controller.test.ts | 74 ++- server/tests/unit/nest/days.service.test.ts | 21 +- .../tests/unit/plugins/rpc-fallbacks.test.ts | 2 +- shared/src/i18n/ar/dayplan.ts | 24 + shared/src/i18n/br/dayplan.ts | 24 + shared/src/i18n/ca/dayplan.ts | 26 +- shared/src/i18n/cs/dayplan.ts | 25 + shared/src/i18n/de/dayplan.ts | 25 + shared/src/i18n/en/dayplan.ts | 24 + shared/src/i18n/es/dayplan.ts | 24 + shared/src/i18n/fr/dayplan.ts | 28 +- shared/src/i18n/gr/dayplan.ts | 25 + shared/src/i18n/hu/dayplan.ts | 25 + shared/src/i18n/id/dayplan.ts | 24 + shared/src/i18n/it/dayplan.ts | 27 +- shared/src/i18n/ja/dayplan.ts | 24 + shared/src/i18n/ko/dayplan.ts | 24 + shared/src/i18n/nl/dayplan.ts | 24 + shared/src/i18n/pl/dayplan.ts | 25 + shared/src/i18n/ru/dayplan.ts | 25 + shared/src/i18n/sv/dayplan.ts | 24 + shared/src/i18n/tr/dayplan.ts | 24 + shared/src/i18n/uk/dayplan.ts | 25 + shared/src/i18n/vi/dayplan.ts | 24 + shared/src/i18n/zh-TW/dayplan.ts | 23 + shared/src/i18n/zh/dayplan.ts | 23 + wiki/Day-Plans-and-Notes.md | 14 +- wiki/MCP-Tools-and-Resources.md | 2 +- 78 files changed, 3086 insertions(+), 170 deletions(-) create mode 100644 client/src/components/shared/DayImpactList.test.tsx create mode 100644 client/src/components/shared/DayImpactList.tsx create mode 100644 client/src/mobile/components/MDayImpactList.tsx create mode 100644 client/src/pages/tripPlanner/useDayDelete.ts create mode 100644 client/src/utils/dayDeleteImpact.test.ts create mode 100644 client/src/utils/dayDeleteImpact.ts create mode 100644 client/src/utils/dayImpactLines.test.ts create mode 100644 client/src/utils/dayImpactLines.ts create mode 100644 client/src/utils/dayLabel.test.ts create mode 100644 client/src/utils/dayLabel.ts create mode 100644 server/src/nest/days/day-removal.service.ts create mode 100644 server/src/nest/trips/trip-select.ts create mode 100644 server/tests/unit/nest/day-removal.service.test.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index d338a41a2..2913cfc31 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,7 +1,7 @@ import { isEffectivelyOffline } from '../sync/networkMode' import axios, { AxiosInstance } from 'axios' import type { z } from 'zod' -import type { Place } from '../types' +import type { Place, Trip } from '../types' import type { TransitProvider } from '@trek/shared' import { randomId } from '../utils/randomId' import { @@ -438,7 +438,10 @@ export const daysApi = { update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), // Whole-day default route mode (#1281); per-segment leg modes override it. updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data), - delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), + // Answers with the trip in list shape: its day count changed, and its end + // date when the day took the last date along. + delete: (tripId: number | string, dayId: number | string): Promise<{ success: boolean; trip?: Trip }> => + apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data), } diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index bff3a255b..d83c435e3 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -76,6 +76,8 @@ interface DayPlanSidebarProps { onReorder: (dayId: number, orderedIds: number[]) => void onReorderDays?: (orderedIds: number[]) => void onAddDay?: (position?: number) => void + /** Asks to delete a day from the reorder dialog; the planner owns the question. */ + onDeleteDay?: (dayId: number) => void /** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */ onUpdateDayTitle: (dayId: number, title: string) => void /** The day route is computed by the planner page itself; kept for the existing call sites. */ @@ -146,7 +148,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, onSelectDay, onPlaceClick, onDayDetail, accommodations = [], - onReorder, onReorderDays, onAddDay, + onReorder, onReorderDays, onAddDay, onDeleteDay, onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations = [], visibleConnectionIds = [], @@ -1129,6 +1131,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { onReorder, onReorderDays, onAddDay, + onDeleteDay, onAssignToDay, onRemoveAssignment, onEditPlace, @@ -1331,6 +1334,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP onReorder, onReorderDays, onAddDay, + onDeleteDay, onAssignToDay, onRemoveAssignment, onEditPlace, @@ -1590,6 +1594,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP canManageShare={canManageShare} onReorderDays={onReorderDays} onAddDay={onAddDay} + onDeleteDay={onDeleteDay} /> {/* Tagesliste */} diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx index 8f01cbc0e..f3eaa7800 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023 +// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-024 import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' import { downloadTripPDF } from '../PDF/TripPDF' @@ -233,6 +233,16 @@ describe('DayPlanSidebarToolbar', () => { expect(onAddDay).toHaveBeenCalledWith() }) + it('FE-PLANNER-DPTOOLBAR-024: the popup hands a delete request for its day to the planner', async () => { + const user = userEvent.setup() + const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 }), buildDay({ id: 11, title: 'Day 2', day_number: 2 })] + const onDeleteDay = vi.fn((_id: number) => {}) + render() + await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' })) + await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1]) + expect(onDeleteDay).toHaveBeenCalledWith(11) + }) + it('FE-PLANNER-DPTOOLBAR-021: the reorder button is hidden without edit rights or without days', () => { const days = [buildDay({ id: 10 })] const { rerender } = render() diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.tsx index 94a1833ef..20915a742 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.tsx @@ -37,6 +37,8 @@ interface DayPlanSidebarToolbarProps { canManageShare?: boolean onReorderDays?: (orderedIds: number[]) => void onAddDay?: (position?: number) => void + /** Asks to delete a day from the reorder dialog; without it the dialog has no delete buttons. */ + onDeleteDay?: (dayId: number) => void } export function DayPlanSidebarToolbar({ @@ -44,7 +46,7 @@ export function DayPlanSidebarToolbar({ allConnectionsShown = false, onToggleAllConnections, t, locale, toast, expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel, - canEditDays, canManageShare = true, onReorderDays, onAddDay, + canEditDays, canManageShare = true, onReorderDays, onAddDay, onDeleteDay, }: DayPlanSidebarToolbarProps) { const [reorderOpen, setReorderOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false) @@ -197,6 +199,7 @@ export function DayPlanSidebarToolbar({ locale={locale} onReorder={onReorderDays} onAddDay={() => onAddDay()} + onDeleteDay={onDeleteDay} onClose={() => setReorderOpen(false)} /> diff --git a/client/src/components/Planner/DayReorderPopup.test.tsx b/client/src/components/Planner/DayReorderPopup.test.tsx index a1e0566e5..991c503a8 100644 --- a/client/src/components/Planner/DayReorderPopup.test.tsx +++ b/client/src/components/Planner/DayReorderPopup.test.tsx @@ -1,8 +1,9 @@ -// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-012 +// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-016 import { render, screen, fireEvent } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' import { buildDay } from '../../../tests/helpers/factories' import { DayReorderPopup } from './DayReorderPopup' +import { setForcedOffline } from '../../sync/networkMode' import type { Day } from '../../types' // The component takes `t` as a prop, so returning the key keeps assertions exact. @@ -137,4 +138,47 @@ describe('DayReorderPopup', () => { await user.click(screen.getByText('common.close')) expect(onClose).toHaveBeenCalled() }) + + describe('deleting a day', () => { + afterEach(() => setForcedOffline(false)) + + it('FE-PLANNER-DAYREORDER-013: each row asks to delete its own day, and only asks', async () => { + const user = userEvent.setup() + const onDeleteDay = vi.fn() + const onReorder = vi.fn() + render() + const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' }) + expect(buttons).toHaveLength(3) + await user.click(buttons[1]) + expect(onDeleteDay).toHaveBeenCalledWith(7) + expect(onReorder).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYREORDER-014: without a delete handler the rows carry no delete button', () => { + render() + expect(screen.queryByRole('button', { name: 'dayplan.deleteDay' })).not.toBeInTheDocument() + // The label is still the row's second span, next to its position badge. + expect(rows()[0].querySelectorAll('span')[1].textContent).toBe('Paris') + }) + + it('FE-PLANNER-DAYREORDER-015: the only day of a trip cannot be deleted', async () => { + const user = userEvent.setup() + const onDeleteDay = vi.fn() + render() + const button = screen.getByRole('button', { name: 'dayplan.deleteDay' }) + expect(button).toBeDisabled() + await user.click(button) + expect(onDeleteDay).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYREORDER-016: offline every delete button is off, and hovering says why', async () => { + setForcedOffline(true) + const user = userEvent.setup() + render() + const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' }) + expect(buttons.every(b => (b as HTMLButtonElement).disabled)).toBe(true) + await user.hover(buttons[0].parentElement as HTMLElement) + expect(await screen.findByText('dayplan.daysOffline')).toBeInTheDocument() + }) + }) }) diff --git a/client/src/components/Planner/DayReorderPopup.tsx b/client/src/components/Planner/DayReorderPopup.tsx index aadd121b6..fa0fb699f 100644 --- a/client/src/components/Planner/DayReorderPopup.tsx +++ b/client/src/components/Planner/DayReorderPopup.tsx @@ -1,6 +1,10 @@ import { useState } from 'react' -import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react' +import { GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from 'lucide-react' import Modal from '../shared/Modal' +import Tooltip from '../shared/Tooltip' +import { useNetworkMode } from '../../hooks/useNetworkMode' +import { dayLabel } from '../../utils/dayLabel' +import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact' import type { Day } from '../../types' interface DayReorderPopupProps { @@ -10,29 +14,25 @@ interface DayReorderPopupProps { locale: string onReorder: (orderedIds: number[]) => void onAddDay: () => void + /** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */ + onDeleteDay?: (dayId: number) => void onClose: () => void } /** * Modal for moving whole days around: drag a row by its grip or use the up/down - * arrows, and add a day at the end. Day headers stay untouched — this is the - * single surface for ordering. Reorders are applied optimistically by the store, - * so the list reflects each move immediately. + * arrows, add a day at the end, or delete one. Day headers stay untouched, so + * this is the single surface for ordering. Reorders are applied optimistically + * by the store, so the list reflects each move immediately. A delete only asks: + * the planner shows what goes with the day before anything happens. */ -export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) { +export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onDeleteDay, onClose }: DayReorderPopupProps) { const [dragIndex, setDragIndex] = useState(null) const [overIndex, setOverIndex] = useState(null) + const { offline } = useNetworkMode() const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) - - const label = (day: Day, index: number) => { - if (day.title) return day.title - if (day.date) { - const d = new Date(day.date + 'T00:00:00') - return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' }) - } - return t('dayplan.dayN', { n: index + 1 }) - } + const deleteBlocked = deleteDayBlockedReason(ordered.length, offline, t) const move = (from: number, to: number) => { if (to < 0 || to >= ordered.length || from === to) return @@ -120,7 +120,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, {index + 1} - {label(day, index)} + {dayLabel(day, index, t, locale)} + {onDeleteDay && ( + <> +