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
@@ -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>
)
}