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.
This commit is contained in:
Maurice
2026-09-23 18:42:02 +02:00
parent 436f0740fb
commit 2f6f921aef
78 changed files with 3086 additions and 170 deletions
@@ -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 */}
@@ -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(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn(), onDeleteDay })} />)
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(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: false, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
@@ -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)}
/>
</div>
@@ -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(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay, onReorder })} />)
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(<DayReorderPopup {...makeProps({ days: threeDays() })} />)
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(<DayReorderPopup {...makeProps({ days: [buildDay({ id: 3, title: 'Paris', day_number: 1 })], onDeleteDay })} />)
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(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn() })} />)
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()
})
})
})
@@ -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<number | null>(null)
const [overIndex, setOverIndex] = useState<number | null>(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}
</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label(day, index)}
{dayLabel(day, index, t, locale)}
</span>
<button
type="button"
@@ -140,6 +140,24 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
>
<ArrowDown size={14} strokeWidth={2} />
</button>
{onDeleteDay && (
<>
<span aria-hidden="true" className="mx-0.5 h-5 w-px flex-shrink-0 bg-edge-faint" />
<Tooltip label={deleteBlocked ?? t('dayplan.deleteDay')} placement="left">
<span className="inline-flex flex-shrink-0">
<button
type="button"
onClick={() => onDeleteDay(day.id)}
disabled={!!deleteBlocked}
aria-label={t('dayplan.deleteDay')}
className="grid h-7 w-7 place-items-center rounded-[7px] border border-edge-faint text-content-muted transition-colors hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-content-muted"
>
<Trash2 size={14} strokeWidth={2} />
</button>
</span>
</Tooltip>
</>
)}
</div>
))}
</div>
@@ -1,6 +1,7 @@
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import ConfirmDialog from './ConfirmDialog';
import Modal from './Modal';
describe('ConfirmDialog', () => {
const onClose = vi.fn();
@@ -91,6 +92,44 @@ describe('ConfirmDialog', () => {
process.off('unhandledRejection', unhandled);
});
it('FE-COMP-CONFIRM-010: extra content renders under the message, and the card widens for it', () => {
render(
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Delete it?">
<ul aria-label="consequences"><li>Stay at Harbour Hotel</li></ul>
</ConfirmDialog>
);
const list = screen.getByRole('list', { name: 'consequences' });
expect(list).toHaveTextContent('Stay at Harbour Hotel');
expect(screen.getByText('Delete it?').compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(document.querySelector('.max-w-md')).not.toBeNull();
});
it('FE-COMP-CONFIRM-011: Escape takes back the question only, not the dialog it was asked from', () => {
const closeModal = vi.fn();
render(
<>
<Modal isOpen={true} onClose={closeModal} title="Reorder days">rows</Modal>
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Delete it?" />
</>
);
fireEvent.keyDown(document.body, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
expect(closeModal).not.toHaveBeenCalled();
// Other keys pass through untouched.
fireEvent.keyDown(document.body, { key: 'Enter' });
expect(onClose).toHaveBeenCalledOnce();
});
it('FE-COMP-CONFIRM-012: the danger look comes from the danger tokens, the plain one from the accent', () => {
const { unmount } = render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} />);
expect(screen.getByRole('button', { name: /delete/i })).toHaveClass('bg-danger');
expect(document.querySelector('.bg-danger-soft')).not.toBeNull();
unmount();
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} danger={false} confirmLabel="Keep" />);
expect(screen.getByRole('button', { name: 'Keep' })).toHaveClass('bg-accent', 'text-accent-text');
expect(document.querySelector('.bg-danger-soft')).toBeNull();
});
it('FE-COMP-CONFIRM-008: clicking backdrop calls onClose', async () => {
const user = userEvent.setup();
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="msg" />);
+20 -12
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useCallback } from 'react'
import React, { useEffect, useCallback, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -12,6 +12,8 @@ interface ConfirmDialogProps {
confirmLabel?: string
cancelLabel?: string
danger?: boolean
/** Extra content under the message, such as a list of what the action takes with it. */
children?: ReactNode
}
// Callers commonly pass an async handler that reports its own failure and then
@@ -33,18 +35,23 @@ export default function ConfirmDialog({
confirmLabel,
cancelLabel,
danger = true,
children,
}: ConfirmDialogProps) {
const { t } = useTranslation()
// Captured and stopped here: the question often opens over another dialog
// (a Modal) that closes on Escape too, and one key press should only take
// back the question, not the dialog it was asked from.
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key !== 'Escape') return
e.stopPropagation()
onClose()
}, [onClose])
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEsc)
}
return () => document.removeEventListener('keydown', handleEsc)
if (!isOpen) return
document.addEventListener('keydown', handleEsc, true)
return () => document.removeEventListener('keydown', handleEsc, true)
}, [isOpen, handleEsc])
if (!isOpen) return null
@@ -60,22 +67,23 @@ export default function ConfirmDialog({
>
<div
role="presentation"
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6 bg-surface-card"
className={`trek-modal-enter rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
onClick={e => e.stopPropagation()}
>
<div className="flex items-start gap-4">
{danger && (
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600" />
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-danger-soft flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-danger" />
</div>
)}
<div className="flex-1">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-content">
{title || t('common.confirm')}
</h3>
<p className="mt-1 text-sm text-content-secondary">
{message}
</p>
{children}
</div>
</div>
@@ -88,8 +96,8 @@ export default function ConfirmDialog({
</button>
<button type="button"
onClick={() => { runConfirm(onConfirm); onClose() }}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white ${
danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'
className={`px-4 py-2 text-sm font-medium rounded-lg transition-opacity hover:opacity-90 ${
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
}`}
>
{confirmLabel || t('common.delete')}
@@ -0,0 +1,49 @@
// FE-COMP-DAYIMPACT-001 to FE-COMP-DAYIMPACT-003
import { describe, it, expect } from 'vitest'
import { BedDouble, MapPin } from 'lucide-react'
import { render, screen } from '../../../tests/helpers/render'
import DayImpactList from './DayImpactList'
import MDayImpactList from '../../mobile/components/MDayImpactList'
import type { ImpactLine } from '../../utils/dayImpactLines'
const lines: ImpactLine[] = [
{ key: 'stay-9', icon: BedDouble, tone: 'danger', text: 'Stay at Harbour Hotel', hint: 'Cancelled with its booking.' },
{ key: 'places', icon: MapPin, tone: 'neutral', text: 'Planned places: 3' },
]
describe('DayImpactList', () => {
it('FE-COMP-DAYIMPACT-001: one row per line, text and hint, named for assistive tech', () => {
render(<DayImpactList lines={lines} label="Delete Tue, Oct 13?" />)
const list = screen.getByRole('list', { name: 'Delete Tue, Oct 13?' })
const rows = screen.getAllByRole('listitem')
expect(list).toContainElement(rows[0])
expect(rows).toHaveLength(2)
expect(rows[0]).toHaveTextContent('Stay at Harbour Hotel')
expect(rows[0]).toHaveTextContent('Cancelled with its booking.')
expect(rows[1]).toHaveTextContent('Planned places: 3')
})
it('FE-COMP-DAYIMPACT-002: the tone reaches the row: money in the danger tokens, the rest in the content tokens', () => {
render(<DayImpactList lines={lines} />)
const [stay, places] = screen.getAllByRole('listitem')
expect(stay).toHaveAttribute('data-tone', 'danger')
expect(screen.getByText('Stay at Harbour Hotel')).toHaveClass('text-danger')
expect(stay.querySelector('span')).toHaveClass('bg-danger-soft')
expect(screen.getByText('Planned places: 3')).toHaveClass('text-content')
expect(places.querySelectorAll('p')).toHaveLength(1)
})
it('FE-COMP-DAYIMPACT-003: renders nothing without lines, and the phone skin draws the same rows in its own tokens', () => {
const { container } = render(<DayImpactList lines={[]} />)
expect(container).toBeEmptyDOMElement()
render(<MDayImpactList lines={lines} label="phone" />)
const phone = screen.getByRole('list', { name: 'phone' })
expect(phone.className).toContain('var(--m-inner)')
const rows = phone.querySelectorAll('li')
expect(rows).toHaveLength(2)
// Hairlines between rows, not above the first.
expect(rows[0].className).not.toContain('border-t')
expect(rows[1].className).toContain('border-t')
})
})
@@ -0,0 +1,66 @@
import type { ImpactLine, ImpactTone } from '../../utils/dayImpactLines'
/** The class names one shell draws the list with. */
export interface ImpactListSkin {
list: string
row: string
/** Between two rows; the desktop card divides them itself. */
divider: string
chip: string
iconSize: number
text: string
hint: string
chipTone: Record<ImpactTone, string>
textTone: Record<ImpactTone, string>
}
const DESKTOP: ImpactListSkin = {
list: 'mt-4 overflow-hidden rounded-xl border border-edge-faint bg-surface-secondary divide-y divide-edge-faint',
row: 'flex items-start gap-3 px-3 py-2.5',
divider: '',
chip: 'mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg',
iconSize: 15,
text: 'text-body font-medium leading-snug',
hint: 'mt-0.5 text-caption text-content-muted',
chipTone: {
neutral: 'bg-surface text-content-muted',
muted: 'bg-surface text-content-faint',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
},
textTone: { neutral: 'text-content', muted: 'text-content-muted', warning: 'text-content', danger: 'text-danger' },
}
interface DayImpactListProps {
lines: ImpactLine[]
/** Names the list for assistive tech; the dialog title usually says it already. */
label?: string
/** The phone passes its own; the default is the desktop card. */
skin?: ImpactListSkin
}
/**
* What goes with the days being removed, one row per kind of content: an icon
* chip in the row's tone, the count, and a caption saying what happens to it.
* The rows come ready made from utils/dayImpactLines, and the look comes from a
* skin, so the desktop dialog and the phone sheet share this markup and differ
* only in their tokens.
*/
export default function DayImpactList({ lines, label, skin = DESKTOP }: DayImpactListProps) {
if (lines.length === 0) return null
return (
<ul aria-label={label} className={skin.list}>
{lines.map(({ key, icon: Icon, text, hint, tone }, i) => (
<li key={key} data-tone={tone} className={`${skin.row} ${i > 0 ? skin.divider : ''}`.trim()}>
<span className={`${skin.chip} ${skin.chipTone[tone]}`}>
<Icon size={skin.iconSize} strokeWidth={2} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<p className={`${skin.text} ${skin.textTone[tone]}`}>{text}</p>
{hint && <p className={skin.hint}>{hint}</p>}
</div>
</li>
))}
</ul>
)
}