feat(trips): shortening a trip says first which days go and what is on them

The trip dialog on the desktop and the trip sheet on a phone now ask before a save whose new dates remove days with something on them. The question names those days the way the day list does and lists what goes with them in the same rows the delete day question uses: planned places stay in the place list, notes and day texts are deleted, bookings stay under Bookings, and a stay that checks in or out on a removed day goes as a whole while its booking and expense remain, shown in the danger tone. A moved start gets both questions in one step, with the booking hint following the chosen shift mode, and a save that only drops empty days asks nothing.

The rule that lays the days out moved into shared as planDayGrid, with resolveDayGridRange deciding whether the rows are rebuilt at all. generateDays on the server now carries that plan out in one transaction, so the warning names exactly the days the save takes, and a seeded fuzz test holds the new rebuild to the old one over 300 random day grids. The update_trip tool says in its description what shortening takes and lists removed_days in its answer.

Alongside, the dialog sends a day count only when it changed, so a stale count no longer trims a day just added, stays are reloaded after every date change including the one made on a phone, and the date shift tip now points to adding a day under Reorder days.
This commit is contained in:
Maurice
2026-09-23 19:54:09 +02:00
parent b56f335978
commit 1b63285ad0
50 changed files with 2122 additions and 231 deletions
@@ -1,4 +1,4 @@
// FE-COMP-DAYIMPACT-001 to FE-COMP-DAYIMPACT-003
// FE-COMP-DAYIMPACT-001 to FE-COMP-DAYIMPACT-004
import { describe, it, expect } from 'vitest'
import { BedDouble, MapPin } from 'lucide-react'
import { render, screen } from '../../../tests/helpers/render'
@@ -46,4 +46,21 @@ describe('DayImpactList', () => {
expect(rows[0].className).not.toContain('border-t')
expect(rows[1].className).toContain('border-t')
})
it('FE-COMP-DAYIMPACT-004: the days themselves lead the list as chips, in both skins', () => {
render(<DayImpactList lines={lines} days={['Fri, Oct 9', 'Sat, Oct 10', '+2 more']} label="desk" />)
const desk = screen.getByRole('list', { name: 'desk' })
const [daysRow, stay] = Array.from(desk.querySelectorAll('li'))
expect(daysRow).toHaveAttribute('data-kind', 'days')
expect(Array.from(daysRow.querySelectorAll('div > span')).map(s => s.textContent)).toEqual(['Fri, Oct 9', 'Sat, Oct 10', '+2 more'])
expect(daysRow.querySelector('span')).toHaveClass('bg-warning-soft')
expect(stay).toHaveTextContent('Stay at Harbour Hotel')
render(<MDayImpactList lines={lines} days={['Fri, Oct 9']} label="phone" />)
const phoneRows = screen.getByRole('list', { name: 'phone' }).querySelectorAll('li')
expect(phoneRows).toHaveLength(3)
expect(phoneRows[0].className).not.toContain('border-t')
// The first content row sits under the day chips, so it gets the hairline.
expect(phoneRows[1].className).toContain('border-t')
})
})
+22 -2
View File
@@ -1,3 +1,4 @@
import { CalendarMinus } from 'lucide-react'
import type { ImpactLine, ImpactTone } from '../../utils/dayImpactLines'
/** The class names one shell draws the list with. */
@@ -7,6 +8,8 @@ export interface ImpactListSkin {
/** Between two rows; the desktop card divides them itself. */
divider: string
chip: string
/** One removed day, named in the row above the content rows. */
dayChip: string
iconSize: number
text: string
hint: string
@@ -19,6 +22,7 @@ const DESKTOP: ImpactListSkin = {
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',
dayChip: 'rounded-full border border-edge-faint bg-surface px-2.5 py-0.5 text-caption font-medium text-content-secondary',
iconSize: 15,
text: 'text-body font-medium leading-snug',
hint: 'mt-0.5 text-caption text-content-muted',
@@ -33,6 +37,11 @@ const DESKTOP: ImpactListSkin = {
interface DayImpactListProps {
lines: ImpactLine[]
/**
* The days themselves, as chips in a first row: when several days go at
* once, the list names them before it says what is on them.
*/
days?: string[]
/** 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. */
@@ -46,12 +55,23 @@ interface DayImpactListProps {
* 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) {
export default function DayImpactList({ lines, days = [], label, skin = DESKTOP }: DayImpactListProps) {
if (lines.length === 0) return null
const offset = days.length > 0 ? 1 : 0
return (
<ul aria-label={label} className={skin.list}>
{offset > 0 && (
<li data-kind="days" className={skin.row}>
<span className={`${skin.chip} ${skin.chipTone.warning}`}>
<CalendarMinus size={skin.iconSize} strokeWidth={2} aria-hidden="true" />
</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-1.5 pt-1">
{days.map((name, i) => <span key={`${i}-${name}`} className={skin.dayChip}>{name}</span>)}
</div>
</li>
)}
{lines.map(({ key, icon: Icon, text, hint, tone }, i) => (
<li key={key} data-tone={tone} className={`${skin.row} ${i > 0 ? skin.divider : ''}`.trim()}>
<li key={key} data-tone={tone} className={`${skin.row} ${i + offset > 0 ? skin.divider : ''}`.trim()}>
<span className={`${skin.chip} ${skin.chipTone[tone]}`}>
<Icon size={skin.iconSize} strokeWidth={2} aria-hidden="true" />
</span>