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
@@ -0,0 +1,73 @@
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import DayImpactList from '../shared/DayImpactList'
import { dayChips, shrinkTripLines, type ShiftMode } from '../../utils/dayImpactLines'
import type { RangeCheck } from '../../hooks/useTripRangeGuard'
interface TripDateReviewProps {
/** The start of a dated trip moved: ask how bookings follow (#1288). */
askShift: boolean
shiftMode: ShiftMode
onShiftMode: (mode: ShiftMode) => void
/** What the new dates remove, if anything worth a word. */
removal: RangeCheck
error?: string
}
/**
* The step the trip dialog puts between a change of dates and the save: how
* bookings follow a moved start, and which days the new range removes with what
* is on them. Either part can stand alone. The list's booking hint follows the
* shift mode picked above it, because the two modes leave those bookings in
* different places.
*/
export default function TripDateReview({ askShift, shiftMode, onShiftMode, removal, error }: TripDateReviewProps) {
const { t } = useTranslation()
const modes: { mode: ShiftMode; label: string; desc: string }[] = [
{ mode: 'keep_bookings', label: t('dashboard.dateShiftKeepBookings'), desc: t('dashboard.dateShiftKeepBookingsDesc') },
{ mode: 'shift_all', label: t('dashboard.dateShiftAll'), desc: t('dashboard.dateShiftAllDesc') },
]
return (
<div className="space-y-3">
{error && (
<div className="p-3 bg-danger-soft border border-danger/30 rounded-xl text-body text-danger">{error}</div>
)}
{askShift && (
<>
<p className="text-body text-content-secondary">{t('dashboard.dateShiftIntro')}</p>
{modes.map(({ mode, label, desc }) => (
<label key={mode}
className={`flex items-start gap-3 p-3 border rounded-xl cursor-pointer transition-colors ${shiftMode === mode ? 'border-accent bg-surface-selected' : 'border-edge hover:bg-surface-hover'}`}>
<input type="radio" name="date_shift_mode" value={mode} checked={shiftMode === mode}
onChange={() => onShiftMode(mode)} className="mt-1 accent-[var(--accent)]" />
<span className="block text-body font-medium text-content">
{label}
<span className="block text-body font-normal text-content-muted mt-0.5">{desc}</span>
</span>
</label>
))}
</>
)}
{removal === 'unknown' && (
<div role="alert" className="flex items-start gap-3 rounded-xl border border-edge-faint bg-surface-secondary p-3">
<span className="mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-danger-soft text-danger">
<AlertTriangle size={15} strokeWidth={2} aria-hidden="true" />
</span>
<p className="text-body text-content">{t('dashboard.shrinkUnknown')}</p>
</div>
)}
{removal && removal !== 'unknown' && (
<div className={askShift ? 'border-t border-edge-faint pt-3' : ''}>
<p className="text-body text-content-secondary">{t('dashboard.shrinkIntro')}</p>
<DayImpactList
lines={shrinkTripLines(removal, t, shiftMode)}
days={dayChips(removal.dayLabels, t)}
label={t('dashboard.shrinkTitle')}
/>
</div>
)}
{askShift && <p className="text-caption text-content-faint">{t('dashboard.dateShiftHint')}</p>}
</div>
)
}
@@ -1,4 +1,4 @@
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-084
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-095
import type { Mock } from 'vitest';
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -8,9 +8,9 @@ import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import { buildUser, buildTrip, buildDay, buildAssignment, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import type { Trip } from '../../types';
import type { Accommodation, Reservation, Trip } from '../../types';
import { MAX_TRIP_DAYS } from '@trek/shared';
import TripFormModal from './TripFormModal';
@@ -1290,4 +1290,169 @@ describe('TripFormModal', () => {
expect.objectContaining({ title: 'Renamed', start_date: '2020-01-01', end_date: '2030-01-01' })
));
});
// ── Shortening: the warning before days go ────────────────────────────────
/** A week in October with a place on its last two days, and the trip's bookings and stays. */
const shrinkableTrip = () => buildTrip({ id: 1, title: 'Coast week', start_date: '2026-10-01', end_date: '2026-10-07' });
const seedTripDays = (extras: { reservations?: Reservation[]; accommodations?: Accommodation[]; empty?: boolean } = {}) => {
const days = Array.from({ length: 7 }, (_, i) => buildDay({
id: 300 + i,
trip_id: 1,
day_number: i + 1,
date: `2026-10-0${i + 1}`,
assignments: !extras.empty && i >= 5 ? [buildAssignment({ day_id: 300 + i, place: buildPlace({ id: 400 + i }) })] : [],
}));
server.use(
http.get('/api/trips/:id/days', () => HttpResponse.json({ days })),
http.get('/api/trips/:id/reservations', () => HttpResponse.json({ reservations: extras.reservations ?? [] })),
http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ accommodations: extras.accommodations ?? [] })),
);
};
const changeEndDate = async (user: ReturnType<typeof userEvent.setup>, iso: string) => {
await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[1]);
const input = screen.getByPlaceholderText('DD.MM.YYYY');
fireEvent.change(input, { target: { value: iso } });
fireEvent.keyDown(input, { key: 'Enter' });
};
it('FE-COMP-TRIPFORM-088: an earlier end asks first and names the days it takes, with what is on them', async () => {
seedTripDays({
accommodations: [{ id: 9, trip_id: 1, start_day_id: 304, end_day_id: 306, place_name: 'Harbour Hotel' } as Accommodation],
reservations: [buildReservation({ id: 70, day_id: 304, type: 'hotel', title: 'Harbour, 2 nights', accommodation_id: 9 })],
});
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={onSave} />);
await changeEndDate(user, '2026-10-05');
await user.click(screen.getByRole('button', { name: /Update/i }));
const list = await screen.findByRole('list', { name: 'Remove days?' });
expect(screen.getAllByText('Remove days?').length).toBeGreaterThan(0);
expect(screen.getByText('Saving the new dates removes these days:')).toBeInTheDocument();
expect(within(list).getByText('Tue, Oct 6')).toBeInTheDocument();
expect(within(list).getByText('Wed, Oct 7')).toBeInTheDocument();
expect(within(list).getByText('Stay at Harbour Hotel').closest('li')).toHaveAttribute('data-tone', 'danger');
expect(within(list).getByText(/Its booking "Harbour, 2 nights" and its expense stay under Bookings/)).toBeInTheDocument();
expect(within(list).getByText('Planned places: 2')).toBeInTheDocument();
// No start moved, so no shift question.
expect(screen.queryByText('Keep bookings on their dates')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove days and save' })).toHaveClass('bg-danger');
expect(onSave).not.toHaveBeenCalled();
});
it('FE-COMP-TRIPFORM-089: a later start that also shortens asks both in one step', async () => {
seedTripDays();
const user = userEvent.setup();
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={vi.fn()} />);
// Moving the start keeps the length, so the end is pulled back afterwards.
await changeStartDate(user, '2026-10-03');
await changeEndDate(user, '2026-10-07');
await user.click(screen.getByRole('button', { name: /Update/i }));
await screen.findByText('Keep bookings on their dates');
expect(screen.getByText('New start date')).toBeInTheDocument();
const list = screen.getByRole('list', { name: 'Remove days?' });
expect(within(list).getByText('The last days go, not the first')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove days and save' })).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-090: a longer trip, or a shorter one that loses only empty days, saves without asking', async () => {
seedTripDays({ empty: true });
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={onSave} />);
await changeEndDate(user, '2026-10-05');
await user.click(screen.getByRole('button', { name: /Update/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ end_date: '2026-10-05' })));
expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ date_shift_mode: expect.anything() }));
expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-091: confirming saves the held payload, with the shift mode only when it was asked', async () => {
seedTripDays();
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={onSave} />);
await changeEndDate(user, '2026-10-05');
await user.click(screen.getByRole('button', { name: /Update/i }));
await user.click(await screen.findByRole('button', { name: 'Remove days and save' }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ start_date: '2026-10-01', end_date: '2026-10-05' }));
expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ date_shift_mode: expect.anything() }));
});
it('FE-COMP-TRIPFORM-092: the booking hint follows the shift mode picked above the list', async () => {
seedTripDays({ reservations: [buildReservation({ id: 71, day_id: 306, title: 'Ferry' })] });
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={onSave} />);
await changeStartDate(user, '2026-10-02');
await changeEndDate(user, '2026-10-06');
await user.click(screen.getByRole('button', { name: /Update/i }));
const list = await screen.findByRole('list', { name: 'Remove days?' });
expect(within(list).getByText(/One whose date is still part of the trip goes back onto that day/)).toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: /Shift everything/i }));
expect(within(list).getByText('They stay under Bookings, without a day.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Remove days and save' }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ date_shift_mode: 'shift_all' })));
});
it('FE-COMP-TRIPFORM-093: Back from the warning returns to the form and saves nothing', async () => {
seedTripDays();
const user = userEvent.setup();
const onSave = vi.fn();
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={onSave} />);
await changeEndDate(user, '2026-10-05');
await user.click(screen.getByRole('button', { name: /Update/i }));
await screen.findByRole('list', { name: 'Remove days?' });
await user.click(screen.getByRole('button', { name: /Back/i }));
await waitFor(() => expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument());
expect(screen.getByDisplayValue('Coast week')).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('FE-COMP-TRIPFORM-094: when the days cannot be read it still warns, in general terms', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
server.use(http.get('/api/trips/:id/days', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
const user = userEvent.setup();
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={vi.fn()} />);
await changeEndDate(user, '2026-10-05');
await user.click(screen.getByRole('button', { name: /Update/i }));
expect(await screen.findByRole('alert')).toHaveTextContent('The days of this trip could not be checked.');
expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove days and save' })).toBeInTheDocument();
spy.mockRestore();
});
it('FE-COMP-TRIPFORM-095: a trip without dates sends its day count only when it changed', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
const undated = buildTrip({ id: 1, title: 'Someday', start_date: null, end_date: null, day_count: 4 });
const { unmount } = render(<TripFormModal {...defaultProps} trip={undated} onSave={onSave} />);
await user.click(screen.getByRole('button', { name: /Update/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).not.toHaveProperty('day_count');
unmount();
render(<TripFormModal {...defaultProps} trip={undated} onSave={onSave} />);
const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement;
fireEvent.change(dayInput, { target: { value: '6' } });
await user.click(screen.getByRole('button', { name: /Update/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(2));
expect(onSave.mock.calls[1][0]).toMatchObject({ day_count: 6 });
});
});
+61 -45
View File
@@ -14,8 +14,23 @@ import { getApiErrorMessage, type Trip } from '../../types'
import { MAX_TRIP_DAYS, tripSpanDays, type TripCreateRequest } from '@trek/shared'
import { NumericInput } from '../shared/NumericInput'
import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants'
import TripDateReview from './TripDateReview'
import { useTripRangeGuard, type RangeCheck } from '../../hooks/useTripRangeGuard'
import type { ShiftMode } from '../../utils/dayImpactLines'
type DateShiftMode = 'keep_bookings' | 'shift_all'
type DateShiftMode = ShiftMode
type TripPayload = TripCreateRequest & { date_shift_mode?: DateShiftMode }
/**
* A save held back for a look first: the start of a dated trip moved and the
* bookings need a direction (#1288), or the new range removes days with
* something on them. The payload waits here until the traveller confirms.
*/
interface PendingReview {
payload: TripPayload
askShift: boolean
removal: RangeCheck
}
interface TripFormModalProps {
isOpen: boolean
@@ -80,10 +95,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([])
const [memberSelectValue, setMemberSelectValue] = useState('')
// Set when a start-date change on a dated trip needs the user to pick how the
// itinerary follows the new dates (#1288); holds the payload awaiting that choice.
const [pendingDateShift, setPendingDateShift] = useState<(TripCreateRequest & { date_shift_mode?: DateShiftMode }) | null>(null)
const [pendingReview, setPendingReview] = useState<PendingReview | null>(null)
const [dateShiftMode, setDateShiftMode] = useState<DateShiftMode>('keep_bookings')
const rangeGuard = useTripRangeGuard()
const busy = isLoading || rangeGuard.checking
useEffect(() => {
if (trip) {
@@ -115,7 +130,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
setCoverSearchResults([])
setCoverSearchError('')
setSelectedMembers([])
setPendingDateShift(null)
setPendingReview(null)
setDateShiftMode('keep_bookings')
setError('')
setExistingMembers([])
@@ -162,28 +177,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
setError(t('dashboard.dayCountRequired')); return
}
}
const payload: TripCreateRequest & { date_shift_mode?: DateShiftMode } = {
// The day count goes along only when it says something new. The trip's own
// count goes stale in the planner (adding a day does not reload the trip),
// and sending the stale number trims the day just added off a trip that was
// only renamed. On a trip left without dates the server keeps its days anyway.
const dayCount = Number(formData.day_count)
const sendDayCount = !formData.start_date && !formData.end_date && (!isEditing || dayCount !== trip?.day_count)
const payload: TripPayload = {
title: formData.title.trim(),
description: formData.description.trim() || null,
start_date: formData.start_date || null,
end_date: formData.end_date || null,
currency: formData.currency,
reminder_days: formData.reminder_days,
...(!formData.start_date && !formData.end_date ? { day_count: Number(formData.day_count) } : {}),
...(sendDayCount ? { day_count: dayCount } : {}),
}
// Moving the start of a dated trip shifts the whole day grid, so let the user
// choose how bookings follow before anything is saved (#1288). End-date-only
// changes don't shift days and dateless transitions have nothing to shift.
if (isEditing && trip?.start_date && trip?.end_date
&& payload.start_date && payload.end_date && payload.start_date !== trip.start_date) {
const askShift = !!(isEditing && trip?.start_date && trip?.end_date
&& payload.start_date && payload.end_date && payload.start_date !== trip.start_date)
// And say which days the new range removes, with what is on them, before
// they are gone: the server takes the last days by position.
const removal = isEditing && trip ? await rangeGuard.check(trip, payload) : null
if (askShift || removal) {
setDateShiftMode('keep_bookings')
setPendingDateShift(payload)
setPendingReview({ payload, askShift, removal })
return
}
await performSave(payload)
}
const performSave = async (payload: TripCreateRequest & { date_shift_mode?: DateShiftMode }) => {
const performSave = async (payload: TripPayload) => {
setIsLoading(true)
try {
const result = await onSave(payload)
@@ -368,6 +393,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const labelCls = "flex items-center gap-1 text-caption font-semibold uppercase tracking-[0.14em] text-content-faint mb-2"
const ghostBtnCls = "px-4 py-2.5 text-body font-medium text-content-secondary hover:text-content border border-edge rounded-xl hover:bg-surface-hover transition-colors"
const primaryBtnCls = "px-5 py-2.5 text-body font-medium bg-accent hover:bg-accent-hover disabled:opacity-50 text-accent-text rounded-xl shadow-card transition-colors flex items-center gap-2"
const dangerBtnCls = "px-5 py-2.5 text-body font-medium bg-danger hover:opacity-90 disabled:opacity-50 text-white rounded-xl shadow-card transition-opacity flex items-center gap-2"
/* Two columns once there is room for them: the form had grown to eight stacked
blocks and the create button sat a full screen below the title. Left is what
the trip looks like, right is when and with whom. One column below md. */
@@ -378,22 +404,26 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<Modal
isOpen={isOpen}
onClose={onClose}
title={pendingDateShift ? t('dashboard.dateShiftTitle') : isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
/* The date-shift step is two radio buttons and a sentence — it would look
title={pendingReview
? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle')
: isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
/* The review step is a few radio buttons and a short list; it would look
lost across the width the form itself needs. */
size={pendingDateShift ? 'md' : '2xl'}
size={pendingReview ? 'md' : '2xl'}
footer={
<div className="flex gap-3 justify-end">
{pendingDateShift ? (
{pendingReview ? (
<>
<button type="button" onClick={() => setPendingDateShift(null)} disabled={isLoading} className={ghostBtnCls}>
<button type="button" onClick={() => setPendingReview(null)} disabled={busy} className={ghostBtnCls}>
{t('common.back')}
</button>
<button type="button" onClick={() => performSave({ ...pendingDateShift, date_shift_mode: dateShiftMode })} disabled={isLoading}
className={primaryBtnCls}>
{/* Removing days with something on them is the one save here that
loses work, so it asks in the danger tone and says so. */}
<button type="button" onClick={() => performSave(pendingReview.askShift ? { ...pendingReview.payload, date_shift_mode: dateShiftMode } : pendingReview.payload)} disabled={busy}
className={pendingReview.removal ? dangerBtnCls : primaryBtnCls}>
{isLoading
? <><div className="w-4 h-4 border-2 border-accent-text/30 border-t-accent-text rounded-full animate-spin" />{t('common.saving')}</>
: t('common.update')}
? <><div className={`w-4 h-4 border-2 rounded-full animate-spin ${pendingReview.removal ? 'border-white/30 border-t-white' : 'border-accent-text/30 border-t-accent-text'}`} />{t('common.saving')}</>
: t(pendingReview.removal ? 'dashboard.shrinkConfirm' : 'common.update')}
</button>
</>
) : (
@@ -401,8 +431,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<button type="button" onClick={onClose} className={ghostBtnCls}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isLoading} className={primaryBtnCls}>
{isLoading
<button type="button" onClick={handleSubmit} disabled={busy} className={primaryBtnCls}>
{busy
? <><div className="w-4 h-4 border-2 border-accent-text/30 border-t-accent-text rounded-full animate-spin" />{t('common.saving')}</>
: isEditing ? t('common.update') : t('dashboard.createTrip')}
</button>
@@ -411,30 +441,16 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</div>
}
>
{pendingDateShift && (
<div className="space-y-3">
{error && (
<div className="p-3 bg-danger-soft border border-danger/30 rounded-xl text-body text-danger">{error}</div>
)}
<p className="text-body text-content-secondary">{t('dashboard.dateShiftIntro')}</p>
{([
{ mode: 'keep_bookings' as DateShiftMode, label: t('dashboard.dateShiftKeepBookings'), desc: t('dashboard.dateShiftKeepBookingsDesc') },
{ mode: 'shift_all' as DateShiftMode, label: t('dashboard.dateShiftAll'), desc: t('dashboard.dateShiftAllDesc') },
]).map(({ mode, label, desc }) => (
<label key={mode}
className={`flex items-start gap-3 p-3 border rounded-xl cursor-pointer transition-colors ${dateShiftMode === mode ? 'border-accent bg-surface-selected' : 'border-edge hover:bg-surface-hover'}`}>
<input type="radio" name="date_shift_mode" value={mode} checked={dateShiftMode === mode}
onChange={() => setDateShiftMode(mode)} className="mt-1 accent-[var(--accent)]" />
<span className="block text-body font-medium text-content">
{label}
<span className="block text-body font-normal text-content-muted mt-0.5">{desc}</span>
</span>
</label>
))}
<p className="text-caption text-content-faint">{t('dashboard.dateShiftHint')}</p>
</div>
{pendingReview && (
<TripDateReview
askShift={pendingReview.askShift}
shiftMode={dateShiftMode}
onShiftMode={setDateShiftMode}
removal={pendingReview.removal}
error={error}
/>
)}
<form onSubmit={handleSubmit} className={pendingDateShift ? 'hidden' : 'space-y-4'} onPaste={handlePaste}>
<form onSubmit={handleSubmit} className={pendingReview ? 'hidden' : 'space-y-4'} onPaste={handlePaste}>
{error && (
<div className="p-3 bg-danger-soft border border-danger/30 rounded-xl text-body text-danger">{error}</div>
)}
@@ -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>
@@ -0,0 +1,93 @@
/**
* FE-HOOK-RANGEGUARD-001 to FE-HOOK-RANGEGUARD-005: the question before a trip
* dialog saves new dates. The desktop dialog and the phone sheet both render
* what this answers, so what it reads, when it reads nothing, and what it says
* when it cannot read are pinned here once. The repos are mocked; the layout
* rule is the real planDayGrid from shared, the one the server runs.
*/
import { createElement, type ReactNode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { TranslationProvider } from '../i18n/TranslationContext'
import { buildAssignment, buildDay, buildPlace } from '../../tests/helpers/factories'
import type { Accommodation, Day, Reservation } from '../types'
import { useTripRangeGuard, type RangeCheck, type RangeRemoval } from './useTripRangeGuard'
const repos = {
days: vi.fn<() => Promise<{ days: Day[] }>>(),
reservations: vi.fn<() => Promise<{ reservations: Reservation[] }>>(),
accommodations: vi.fn<() => Promise<{ accommodations: Accommodation[] }>>(),
}
vi.mock('../repo/dayRepo', () => ({ dayRepo: { list: () => repos.days() } }))
vi.mock('../repo/reservationRepo', () => ({ reservationRepo: { list: () => repos.reservations() } }))
vi.mock('../repo/accommodationRepo', () => ({ accommodationRepo: { list: () => repos.accommodations() } }))
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children)
const trip = { id: 7, start_date: '2026-10-01', end_date: '2026-10-05' }
/** Five dated days; the ones named get a place. */
function fiveDays(planned: number[] = []): Day[] {
return Array.from({ length: 5 }, (_, i) => buildDay({
id: i + 1,
day_number: i + 1,
date: `2026-10-0${i + 1}`,
assignments: planned.includes(i + 1) ? [buildAssignment({ day_id: i + 1, place: buildPlace({ id: 90 + i }) })] : [],
}))
}
async function ask(payload: Parameters<ReturnType<typeof useTripRangeGuard>['check']>[1]): Promise<RangeCheck> {
const { result } = renderHook(() => useTripRangeGuard(), { wrapper })
let answer: RangeCheck = null
await act(async () => { answer = await result.current.check(trip, payload) })
expect(result.current.checking).toBe(false)
return answer
}
beforeEach(() => {
repos.days.mockReset().mockResolvedValue({ days: fiveDays([4, 5]) })
repos.reservations.mockReset().mockResolvedValue({ reservations: [] })
repos.accommodations.mockReset().mockResolvedValue({ accommodations: [] })
})
describe('useTripRangeGuard', () => {
it('FE-HOOK-RANGEGUARD-001: a save that keeps the dates is answered without reading anything', async () => {
const rename = { title: 'Renamed', start_date: trip.start_date, end_date: trip.end_date }
expect(await ask(rename)).toBeNull()
expect(await ask({})).toBeNull()
expect(repos.days).not.toHaveBeenCalled()
expect(repos.reservations).not.toHaveBeenCalled()
expect(repos.accommodations).not.toHaveBeenCalled()
})
it('FE-HOOK-RANGEGUARD-002: an earlier end reads the three repos and names the days it takes, with what is on them', async () => {
const answer = await ask({ start_date: '2026-10-01', end_date: '2026-10-03' }) as RangeRemoval
expect(repos.days).toHaveBeenCalledTimes(1)
expect(repos.reservations).toHaveBeenCalledTimes(1)
expect(repos.accommodations).toHaveBeenCalledTimes(1)
expect(answer.removedDays.map(r => r.day.id)).toEqual([4, 5])
expect(answer.content.places).toBe(2)
expect(answer.startMoved).toBe(false)
// Named the way the day list names them: the date, short.
expect(answer.dayLabels).toEqual(['Sun, Oct 4', 'Mon, Oct 5'])
})
it('FE-HOOK-RANGEGUARD-003: days that go empty ask nothing', async () => {
repos.days.mockResolvedValue({ days: fiveDays([1, 2]) })
expect(await ask({ start_date: '2026-10-01', end_date: '2026-10-03' })).toBeNull()
})
it('FE-HOOK-RANGEGUARD-004: when the days cannot be read the answer is unknown, not silence', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
repos.reservations.mockRejectedValue(new Error('500'))
expect(await ask({ start_date: '2026-10-01', end_date: '2026-10-03' })).toBe('unknown')
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
it('FE-HOOK-RANGEGUARD-005: a later start with the same end takes the last days and says the start moved', async () => {
const answer = await ask({ start_date: '2026-10-03', end_date: '2026-10-05' }) as RangeRemoval
expect(answer.removedDays.map(r => r.day.id)).toEqual([4, 5])
expect(answer.startMoved).toBe(true)
})
})
+87
View File
@@ -0,0 +1,87 @@
import { useCallback, useState } from 'react'
import { planDayGrid, resolveDayGridRange } from '@trek/shared'
import { useTranslation } from '../i18n'
import { dayRepo } from '../repo/dayRepo'
import { reservationRepo } from '../repo/reservationRepo'
import { accommodationRepo } from '../repo/accommodationRepo'
import { dayLabel } from '../utils/dayLabel'
import { hasVisibleContent, tripRangeImpact, type TripRangeImpact } from '../utils/tripRangeImpact'
import type { AssignmentsMap, DayNotesMap, Trip } from '../types'
/** Days a save would remove, with the names the day list gives them. */
export interface RangeRemoval extends TripRangeImpact {
dayLabels: string[]
}
/**
* The answer before a save: the days it removes with something on them, null
* when it removes nothing worth a question, or 'unknown' when the trip's days
* could not be read, so the dialog can still warn in general terms.
*/
export type RangeCheck = RangeRemoval | 'unknown' | null
/** The fields of a trip save that move the day grid. */
export interface RangePayload {
start_date?: string | null
end_date?: string | null
day_count?: number | null
}
/**
* Asks, before a trip dialog saves new dates, which days the save would remove
* and what is on them. It reads the trip's days, bookings and stays through the
* repos and lays the days out with planDayGrid, the rule the server rebuilds
* them by, so the warning names exactly the days the save takes. A save that
* does not rebuild the days is answered without reading anything.
*
* Used by the desktop trip dialog and the phone's trip sheet alike; both only
* render what comes back.
*/
export function useTripRangeGuard(): { checking: boolean; check: (trip: Pick<Trip, 'id' | 'start_date' | 'end_date'>, payload: RangePayload) => Promise<RangeCheck> } {
const { t, locale } = useTranslation()
const [checking, setChecking] = useState(false)
const check = useCallback(async (trip: Pick<Trip, 'id' | 'start_date' | 'end_date'>, payload: RangePayload): Promise<RangeCheck> => {
const range = resolveDayGridRange(trip, payload)
if (!range.regenerate) return null
setChecking(true)
try {
const [{ days }, { reservations }, { accommodations }] = await Promise.all([
dayRepo.list(trip.id),
reservationRepo.list(trip.id),
accommodationRepo.list(trip.id),
])
const plan = planDayGrid({
days: days.map(d => ({
id: d.id,
day_number: d.day_number ?? 0,
date: d.date,
hasPlanItems: (d.assignments?.length ?? 0) > 0 || (d.notes_items?.length ?? 0) > 0,
})),
stays: accommodations,
startDate: range.newStart,
endDate: range.newEnd,
dayCount: range.dayCount,
})
if (plan.removed.length === 0) return null
const assignments: AssignmentsMap = {}
const dayNotes: DayNotesMap = {}
for (const day of days) {
assignments[String(day.id)] = day.assignments ?? []
dayNotes[String(day.id)] = day.notes_items ?? []
}
const startMoved = !!trip.start_date && !!range.newStart && !!range.newEnd && range.newStart !== trip.start_date
const impact = tripRangeImpact(plan, days, { assignments, dayNotes, reservations, accommodations }, { startMoved })
if (!hasVisibleContent(impact)) return null
return { ...impact, dayLabels: impact.removedDays.map(({ day, index }) => dayLabel(day, index, t, locale)) }
} catch (err: unknown) {
console.error('Failed to read the days a new range removes:', err)
return 'unknown'
} finally {
setChecking(false)
}
}, [t, locale])
return { checking, check }
}
@@ -7,6 +7,7 @@ const PHONE: ImpactListSkin = {
row: 'flex items-start gap-[10px] px-[11px] py-[9px]',
divider: 'border-t border-[color:var(--m-rowbr)]',
chip: 'mt-[1px] flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full',
dayChip: 'rounded-full bg-[color:var(--m-ic)] px-[9px] py-[2px] text-[0.6875rem] font-semibold text-m-ink',
iconSize: 13,
text: 'text-[0.78125rem] font-semibold leading-snug',
hint: 'mt-[2px] text-[0.6875rem] leading-snug text-m-muted',
@@ -25,6 +26,6 @@ const PHONE: ImpactListSkin = {
}
/** The phone's day impact list: DayImpactList drawn in the mobile tokens. */
export default function MDayImpactList({ lines, label }: { lines: ImpactLine[]; label?: string }) {
return <DayImpactList lines={lines} label={label} skin={PHONE} />
export default function MDayImpactList({ lines, days, label }: { lines: ImpactLine[]; days?: string[]; label?: string }) {
return <DayImpactList lines={lines} days={days} label={label} skin={PHONE} />
}
@@ -15,6 +15,10 @@ import { MAX_TRIP_DAYS, tripSpanDays, type Trip, type TripCreateRequest } from '
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MListRow from '../../components/MListRow'
import MDayImpactList from '../../components/MDayImpactList'
import MConfirmSheet from '../settings/MConfirmSheet'
import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard'
import { dayChips, shrinkTripLines } from '../../../utils/dayImpactLines'
interface CoverSearchPhoto {
id: string
@@ -72,6 +76,11 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
const [searchResults, setSearchResults] = useState<CoverSearchPhoto[]>([])
const [searchError, setSearchError] = useState('')
const [searching, setSearching] = useState(false)
// A save whose new dates remove days with something on them, held until the
// traveller confirms. The phone has no shift step, so bookings keep their dates.
const [pendingRemoval, setPendingRemoval] = useState<{ payload: TripCreateRequest; removal: RangeRemoval | 'unknown' } | null>(null)
const rangeGuard = useTripRangeGuard()
const busy = isSaving || rangeGuard.checking
useEffect(() => {
if (!open) return
@@ -87,6 +96,7 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
setSearchResults([])
setSearchError('')
setError('')
setPendingRemoval(null)
}, [trip, open])
// The local file preview is a blob url; release it once a new cover replaces it
@@ -109,25 +119,40 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
setStartDate(value)
}
const handleSave = async () => {
/** The payload the form stands for, or null after putting the reason on screen. */
const validPayload = (): TripCreateRequest | null => {
setError('')
if (!title.trim()) { setError(t('dashboard.titleRequired')); return }
if (!title.trim()) { setError(t('dashboard.titleRequired')); return null }
if (startDate && endDate) {
const span = tripSpanDays(startDate, endDate)
if (span < 1) { setError(t('dashboard.endDateError')); return }
if (span < 1) { setError(t('dashboard.endDateError')); return null }
const datesTouched = !trip || startDate !== (trip.start_date || '') || endDate !== (trip.end_date || '')
if (datesTouched && span > MAX_TRIP_DAYS) { setError(t('dashboard.tripTooLong', { days: MAX_TRIP_DAYS })); return }
if (datesTouched && span > MAX_TRIP_DAYS) { setError(t('dashboard.tripTooLong', { days: MAX_TRIP_DAYS })); return null }
}
return {
title: title.trim(),
description: description.trim() || null,
start_date: startDate || null,
end_date: endDate || null,
currency,
...(!startDate && !endDate && !isEditing ? { day_count: 7 } : {}),
}
}
const handleSave = async () => {
const payload = validPayload()
if (!payload) return
// New dates that remove days with something on them ask first, the same
// question the desktop dialog asks, with the same list.
const removal = isEditing && trip ? await rangeGuard.check(trip, payload) : null
if (removal) { setPendingRemoval({ payload, removal }); return }
await save(payload)
}
const save = async (payload: TripCreateRequest) => {
setIsSaving(true)
try {
const result = await onSave({
title: title.trim(),
description: description.trim() || null,
start_date: startDate || null,
end_date: endDate || null,
currency,
...(!startDate && !endDate && !isEditing ? { day_count: 7 } : {}),
})
const result = await onSave(payload)
const created = result ? result.trip : undefined
if (pendingCoverFile && created?.id) {
try {
@@ -241,7 +266,10 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
const boxCls = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]'
const inputCls = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'
const removalList = pendingRemoval && pendingRemoval.removal !== 'unknown' ? pendingRemoval.removal : null
return (
<>
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
<div className="flex items-center gap-[11px] p-[16px_16px_0]">
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold">
@@ -433,12 +461,39 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
<button
type="button"
onClick={handleSave}
disabled={isSaving}
disabled={busy}
className="flex-1 rounded-full bg-m-act py-[10px] text-[0.8125rem] font-semibold text-m-actfg disabled:opacity-50"
>
{isSaving ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
</button>
</div>
</MSheet>
{/* After the sheet, so it opens on top of it. */}
<MConfirmSheet
open={pendingRemoval != null}
onClose={() => setPendingRemoval(null)}
title={t('dashboard.shrinkTitle')}
message={t(removalList ? 'dashboard.shrinkIntro' : 'dashboard.shrinkUnknown')}
confirmLabel={t('dashboard.shrinkConfirm')}
cancelLabel={t('common.cancel')}
danger
busy={isSaving}
onConfirm={() => {
if (!pendingRemoval) return
const { payload } = pendingRemoval
setPendingRemoval(null)
void save(payload)
}}
>
{removalList && (
<MDayImpactList
lines={shrinkTripLines(removalList, t)}
days={dayChips(removalList.dayLabels, t)}
label={t('dashboard.shrinkTitle')}
/>
)}
</MConfirmSheet>
</>
)
}
+24 -1
View File
@@ -1,4 +1,4 @@
// FE-TSTORE-001 to FE-TSTORE-021 (trip-scoped root store: load, hydrate, refresh, mutate)
// FE-TSTORE-001 to FE-TSTORE-022 (trip-scoped root store: load, hydrate, refresh, mutate)
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
@@ -411,6 +411,29 @@ describe('tripStore', () => {
await expect(useTripStore.getState().updateTrip(1, { title: 'New' })).rejects.toThrow('Not the owner');
expect(useTripStore.getState().trip?.title).toBe('Old');
});
it('FE-TSTORE-022: new dates nudge the planner to reload its stays, a rename does not', async () => {
const refreshes = vi.fn();
window.addEventListener('accommodations:refresh', refreshes);
try {
seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Week', start_date: '2025-06-01', end_date: '2025-06-07' }) });
let answer = buildTrip({ id: 1, title: 'Renamed', start_date: '2025-06-01', end_date: '2025-06-07' });
server.use(
http.put('/api/trips/1', () => HttpResponse.json({ trip: answer })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
);
await useTripStore.getState().updateTrip(1, { title: 'Renamed' });
expect(refreshes).not.toHaveBeenCalled();
// A stay on a day the shorter range removed is gone on the server.
answer = buildTrip({ id: 1, title: 'Renamed', start_date: '2025-06-01', end_date: '2025-06-05' });
await useTripStore.getState().updateTrip(1, { end_date: '2025-06-05' });
expect(refreshes).toHaveBeenCalledTimes(1);
} finally {
window.removeEventListener('accommodations:refresh', refreshes);
}
});
});
describe('addTag', () => {
+7
View File
@@ -215,8 +215,15 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
updateTrip: async (tripId: number | string, data: Partial<Trip> & { date_shift_mode?: 'keep_bookings' | 'shift_all' }) => {
try {
const before = get().trip
const result = await tripsApi.update(tripId, data)
set({ trip: result.trip })
// New dates rebuild the day rows, and a stay on a day that went is gone
// with it. Stays live in the planner, not here, so every caller of this
// action (the phone's trip dialog too) nudges the planner to reload them.
if (before?.start_date !== result.trip?.start_date || before?.end_date !== result.trip?.end_date) {
window.dispatchEvent(new CustomEvent('accommodations:refresh'))
}
const daysData = await dayRepo.list(tripId)
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
+49 -1
View File
@@ -1,4 +1,4 @@
// FE-UTIL-DAYDEL-001 to FE-UTIL-DAYDEL-008
// FE-UTIL-DAYDEL-001 to FE-UTIL-DAYDEL-012
import { describe, it, expect } from 'vitest'
import { buildAssignment, buildDay, buildDayNote, buildPlace, buildReservation } from '../../tests/helpers/factories'
import { contentOnDays, dayDeleteImpact, deleteDayBlockedReason, type DayContentData } from './dayDeleteImpact'
@@ -122,3 +122,51 @@ describe('dayDeleteImpact', () => {
expect(deleteDayBlockedReason(3, false, t)).toBeNull()
})
})
describe('contentOnDays over several days (the shrink warning)', () => {
it('FE-UTIL-DAYDEL-009: counts across all the days at once', () => {
const data = {
...empty(),
assignments: {
'7': [buildAssignment({ day_id: 7, place: buildPlace({ id: 50 }) })],
'8': [buildAssignment({ day_id: 8, place: buildPlace({ id: 51 }) })],
},
dayNotes: { '7': [buildDayNote({ day_id: 7 })], '8': [buildDayNote({ day_id: 8 }), buildDayNote({ day_id: 8 })] },
reservations: [buildReservation({ id: 1, day_id: 7 }), buildReservation({ id: 2, day_id: 8 }), buildReservation({ id: 3, day_id: 9 })],
}
const days = [buildDay({ id: 7, title: 'Harbour day' }), buildDay({ id: 8, notes: 'Ferry at nine' })]
expect(contentOnDays(days, data)).toEqual({ places: 2, notes: 3, texts: 2, bookings: 2, stays: [] })
})
it('FE-UTIL-DAYDEL-010: a stay from one of the days to another counts once', () => {
const data = { ...empty(), accommodations: [stay({ id: 9, start_day_id: 7, end_day_id: 8, place_name: 'Harbour Hotel' })] }
expect(contentOnDays([buildDay({ id: 7 }), buildDay({ id: 8 })], data).stays).toEqual([
{ id: 9, name: 'Harbour Hotel', booking: null },
])
})
it('FE-UTIL-DAYDEL-011: the hotel booking of such a stay is the stay, not another booking', () => {
const data = {
...empty(),
accommodations: [stay({ id: 9, start_day_id: 7, end_day_id: 8 })],
reservations: [buildReservation({ id: 4, day_id: 7, end_day_id: 8, type: 'hotel', title: 'Harbour, 1 night', accommodation_id: 9 })],
}
expect(contentOnDays([buildDay({ id: 7 }), buildDay({ id: 8 })], data)).toMatchObject({
bookings: 0,
stays: [{ id: 9, booking: 'Harbour, 1 night' }],
})
})
it('FE-UTIL-DAYDEL-012: the same place on two days is one place, and the stops a stay wrote are none', () => {
const museum = buildPlace({ id: 50 })
const hotel = buildPlace({ id: 52 })
const data = {
...empty(),
assignments: {
'7': [buildAssignment({ day_id: 7, place: museum }), buildAssignment({ day_id: 7, place: hotel, accommodation_id: 9 })],
'8': [buildAssignment({ day_id: 8, place: museum }), buildAssignment({ day_id: 8, place: hotel, accommodation_id: 9 })],
},
}
expect(contentOnDays([buildDay({ id: 7 }), buildDay({ id: 8 })], data).places).toBe(1)
})
})
+49 -3
View File
@@ -1,8 +1,9 @@
// FE-UTIL-DAYLINES-001 to FE-UTIL-DAYLINES-004
// FE-UTIL-DAYLINES-001 to FE-UTIL-DAYLINES-008
import { describe, it, expect } from 'vitest'
import { BedDouble, CalendarCheck } from 'lucide-react'
import { deleteDayLines, impactLines } from './dayImpactLines'
import { BedDouble, CalendarCheck, CalendarClock } from 'lucide-react'
import { dayChips, deleteDayLines, impactLines, shrinkTripLines } from './dayImpactLines'
import type { DayDeleteImpact } from './dayDeleteImpact'
import type { TripRangeImpact } from './tripRangeImpact'
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
@@ -54,3 +55,48 @@ describe('deleteDayLines', () => {
expect(lines).toEqual([{ key: 'empty', icon: CalendarCheck, tone: 'muted', text: 'dayplan.deleteDayEmpty' }])
})
})
describe('shrinkTripLines', () => {
const shrink = (content: Partial<TripRangeImpact['content']>, startMoved = false): TripRangeImpact => ({
removedDays: [],
startMoved,
content: { places: 0, notes: 0, texts: 0, bookings: 0, stays: [], ...content },
})
it('FE-UTIL-DAYLINES-005: the same rows, with the shortening hints: a removed stay leaves its booking behind', () => {
const lines = shrinkTripLines(shrink({ places: 2, stays: [{ id: 9, name: 'Harbour Hotel', booking: 'HH' }, { id: 10, name: 'Alma', booking: null }] }), t)
expect(lines.map(l => [l.key, l.tone, l.hint])).toEqual([
['stay-9', 'danger', 'dashboard.shrinkStayBookedHint(booking=HH)'],
['stay-10', 'danger', 'dashboard.shrinkStayHint'],
['places', 'neutral', 'dayplan.impactPlacesHint'],
])
})
it('FE-UTIL-DAYLINES-006: the booking hint follows the shift mode', () => {
const hint = (mode?: 'keep_bookings' | 'shift_all') => shrinkTripLines(shrink({ bookings: 2 }), t, mode)[0].hint
expect(hint()).toBe('dashboard.shrinkBookingsHint')
expect(hint('keep_bookings')).toBe('dashboard.shrinkBookingsHint')
expect(hint('shift_all')).toBe('dashboard.shrinkBookingsShiftHint')
// Deleting a day keeps its own hint whatever mode is passed along.
expect(impactLines(shrink({ bookings: 1 }).content, t, 'deleteDay', { shiftMode: 'shift_all' })[0].hint).toBe('dayplan.deleteDayBookingsHint')
})
it('FE-UTIL-DAYLINES-007: a moved start that still takes the last days gets a last row that says so', () => {
const lines = shrinkTripLines(shrink({ notes: 1 }, true), t)
expect(lines[lines.length - 1]).toEqual({
key: 'lastDays',
icon: CalendarClock,
tone: 'warning',
text: 'dashboard.shrinkLastDays',
hint: 'dashboard.shrinkLastDaysHint',
})
expect(shrinkTripLines(shrink({ notes: 1 }), t).map(l => l.key)).toEqual(['notes'])
})
it('FE-UTIL-DAYLINES-008: day chips name six days and sum up the rest', () => {
const labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', 'Day 8']
expect(dayChips(labels.slice(0, 6), t)).toEqual(labels.slice(0, 6))
expect(dayChips(labels, t)).toEqual([...labels.slice(0, 6), 'dashboard.shrinkMoreDays(count=2)'])
expect(dayChips(labels, t, 3)).toEqual(['Mon', 'Tue', 'Wed', 'dashboard.shrinkMoreDays(count=5)'])
})
})
+53 -6
View File
@@ -1,6 +1,7 @@
import { BedDouble, CalendarCheck, CalendarClock, CalendarMinus, MapPin, StickyNote, Ticket, Type } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { DayContent, DayDeleteImpact } from './dayDeleteImpact'
import type { TripRangeImpact } from './tripRangeImpact'
type Translate = (key: string, params?: Record<string, string | number>) => string
@@ -21,11 +22,15 @@ export interface ImpactLine {
}
/**
* The question the list answers. Deleting a day cancels a stay cleanly and lets
* go of its bookings; other ways of losing days treat both differently, so the
* hints are looked up per variant while the rows themselves stay the same.
* The question the list answers. Deleting a day cancels a stay cleanly, with
* its booking and expense. Shortening a trip removes the stay through the day
* rows instead and leaves its booking behind. So the hints are looked up per
* variant while the rows themselves stay the same.
*/
export type ImpactVariant = 'deleteDay'
export type ImpactVariant = 'deleteDay' | 'shrinkTrip'
/** How bookings follow new dates, as the trip dialog lets the traveller choose. */
export type ShiftMode = 'keep_bookings' | 'shift_all'
const HINTS: Record<ImpactVariant, { bookings: string; stay: string; stayBooked: string }> = {
deleteDay: {
@@ -33,6 +38,11 @@ const HINTS: Record<ImpactVariant, { bookings: string; stay: string; stayBooked:
stay: 'dayplan.deleteDayStayHint',
stayBooked: 'dayplan.deleteDayStayBookedHint',
},
shrinkTrip: {
bookings: 'dashboard.shrinkBookingsHint',
stay: 'dashboard.shrinkStayHint',
stayBooked: 'dashboard.shrinkStayBookedHint',
},
}
/**
@@ -40,8 +50,17 @@ const HINTS: Record<ImpactVariant, { bookings: string; stay: string; stayBooked:
* money, then places, notes, day texts and bookings. Kinds with nothing in them
* are left out.
*/
export function impactLines(content: DayContent, t: Translate, variant: ImpactVariant): ImpactLine[] {
const hints = HINTS[variant]
export function impactLines(
content: DayContent,
t: Translate,
variant: ImpactVariant,
options: { shiftMode?: ShiftMode } = {},
): ImpactLine[] {
// With "shift everything" a booking stays glued to its day row, so one on a
// removed day keeps no day at all; the default puts it back by its date.
const hints = variant === 'shrinkTrip' && options.shiftMode === 'shift_all'
? { ...HINTS.shrinkTrip, bookings: 'dashboard.shrinkBookingsShiftHint' }
: HINTS[variant]
const lines: ImpactLine[] = content.stays.map(stay => ({
key: `stay-${stay.id}`,
icon: BedDouble,
@@ -95,3 +114,31 @@ export function deleteDayLines(impact: DayDeleteImpact, t: Translate, formatDate
}
return lines
}
/** Most day chips the list names before it sums up the rest. */
export const MAX_DAY_CHIPS = 6
/** The removed days as chip labels: the first few by name, the rest as "+n more". */
export function dayChips(labels: string[], t: Translate, max = MAX_DAY_CHIPS): string[] {
if (labels.length <= max) return labels
return [...labels.slice(0, max), t('dashboard.shrinkMoreDays', { count: labels.length - max })]
}
/**
* The full list for the warning before a trip is shortened: what sits on the
* days that go, with the booking hint following the chosen shift mode, and a
* last row when a moved start still takes the last days.
*/
export function shrinkTripLines(impact: TripRangeImpact, t: Translate, shiftMode: ShiftMode = 'keep_bookings'): ImpactLine[] {
const lines = impactLines(impact.content, t, 'shrinkTrip', { shiftMode })
if (impact.startMoved) {
lines.push({
key: 'lastDays',
icon: CalendarClock,
tone: 'warning',
text: t('dashboard.shrinkLastDays'),
hint: t('dashboard.shrinkLastDaysHint'),
})
}
return lines
}
+111
View File
@@ -0,0 +1,111 @@
// FE-UTIL-RANGEDEL-001 to FE-UTIL-RANGEDEL-008
import { describe, it, expect } from 'vitest'
import { planDayGrid } from '@trek/shared'
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../tests/helpers/factories'
import { hasVisibleContent, tripRangeImpact } from './tripRangeImpact'
import type { DayContentData } from './dayDeleteImpact'
import type { Accommodation, Day } from '../types'
/**
* The trip of the plan: 1 to 10 October, a place on every day (ids 1 to 10),
* then two days without a date: 11 is empty, 12 has a place on it.
*/
function tenDays(): Day[] {
return [
...Array.from({ length: 10 }, (_, i) =>
buildDay({ id: i + 1, day_number: i + 1, date: `2026-10-${String(i + 1).padStart(2, '0')}` })),
buildDay({ id: 11, day_number: 11, date: null }),
buildDay({ id: 12, day_number: 12, date: null }),
]
}
function contentOn(days: Day[]): DayContentData {
const assignments: DayContentData['assignments'] = {}
for (const day of days) {
if (day.id !== 11) assignments[String(day.id)] = [buildAssignment({ day_id: day.id, place: buildPlace({ id: 100 + day.id }) })]
}
return { assignments, dayNotes: {}, reservations: [], accommodations: [] }
}
function impactFor(start: string | null, end: string | null, options = { startMoved: false }, days = tenDays(), data = contentOn(days)) {
const plan = planDayGrid({
days: days.map(d => ({
id: d.id,
day_number: d.day_number ?? 0,
date: d.date,
hasPlanItems: (data.assignments[String(d.id)]?.length ?? 0) > 0,
})),
stays: data.accommodations,
startDate: start,
endDate: end,
})
return tripRangeImpact(plan, days, data, options)
}
const ids = (impact: ReturnType<typeof impactFor>) => impact.removedDays.map(r => r.day.id)
describe('tripRangeImpact', () => {
it('FE-UTIL-RANGEDEL-001: an earlier end takes the last days with their places, and the empty spare day', () => {
const impact = impactFor('2026-10-01', '2026-10-08')
expect(ids(impact)).toEqual([9, 10, 11])
expect(impact.content.places).toBe(2)
expect(impact.startMoved).toBe(false)
expect(hasVisibleContent(impact)).toBe(true)
})
it('FE-UTIL-RANGEDEL-002: a later start with the same end still takes the last days, and says so', () => {
const impact = impactFor('2026-10-03', '2026-10-10', { startMoved: true })
expect(ids(impact)).toEqual([9, 10, 11])
expect(impact.startMoved).toBe(true)
})
it('FE-UTIL-RANGEDEL-003: moving the whole range drops only the empty spare day, which is not worth a word', () => {
const impact = impactFor('2026-10-05', '2026-10-14', { startMoved: true })
expect(ids(impact)).toEqual([11])
// Nothing dated went, so the "last days" row would be wrong here.
expect(impact.startMoved).toBe(false)
expect(hasVisibleContent(impact)).toBe(false)
})
it('FE-UTIL-RANGEDEL-004: a later end removes nothing', () => {
const impact = impactFor('2026-10-01', '2026-10-11')
expect(impact.removedDays).toEqual([])
expect(hasVisibleContent(impact)).toBe(false)
})
it('FE-UTIL-RANGEDEL-005: clearing the dates keeps every day', () => {
const impact = impactFor(null, null)
expect(impact.removedDays).toEqual([])
expect(impact.content).toEqual({ places: 0, notes: 0, texts: 0, bookings: 0, stays: [] })
})
it('FE-UTIL-RANGEDEL-006: a spare day with only a title goes, and its title is what the warning names', () => {
const days = tenDays().map(d => (d.id === 11 ? { ...d, title: 'Buffer day' } : d))
const impact = impactFor('2026-10-05', '2026-10-14', { startMoved: false }, days)
expect(ids(impact)).toEqual([11])
expect(impact.content.texts).toBe(1)
expect(hasVisibleContent(impact)).toBe(true)
})
it('FE-UTIL-RANGEDEL-007: a stay on a removed day goes as a whole, with its booking, and a booking there counts', () => {
const days = tenDays()
const data = contentOn(days)
data.accommodations = [
{ id: 5, trip_id: 1, start_day_id: 7, end_day_id: 9, place_name: 'Harbour Hotel' } as Accommodation,
{ id: 6, trip_id: 1, start_day_id: 2, end_day_id: 4, place_name: 'Pension Alma' } as Accommodation,
]
data.reservations = [
buildReservation({ id: 40, day_id: 7, type: 'hotel', title: 'Harbour, 2 nights', accommodation_id: 5 }),
buildReservation({ id: 41, day_id: 10, title: 'Ferry' }),
]
const impact = impactFor('2026-10-01', '2026-10-08', { startMoved: false }, days, data)
expect(impact.content.stays).toEqual([{ id: 5, name: 'Harbour Hotel', booking: 'Harbour, 2 nights' }])
expect(impact.content.bookings).toBe(1)
})
it('FE-UTIL-RANGEDEL-008: the removed days come in list order, each with its place in the list', () => {
const days = [...tenDays()].reverse()
const impact = impactFor('2026-10-01', '2026-10-09', { startMoved: false }, days)
expect(impact.removedDays.map(r => [r.day.id, r.index])).toEqual([[10, 9], [11, 10]])
})
})
+57
View File
@@ -0,0 +1,57 @@
import type { DayGridPlan } from '@trek/shared'
import type { Day } from '../types'
import { contentOnDays, type DayContent, type DayContentData } from './dayDeleteImpact'
/** A day a save would remove, with its place in the day list (for "Day n"). */
export interface RemovedDay {
day: Day
index: number
}
/** What a new range takes away: the days, in list order, and what sits on them. */
export interface TripRangeImpact {
removedDays: RemovedDay[]
/**
* The start moved while dated days fall off the end. Plans follow their
* position, so it is the last days that go, not the first: worth saying,
* because it is the opposite of what moving the start suggests.
*/
startMoved: boolean
content: DayContent
}
const byNumber = (a: Day, b: Day): number => (a.day_number ?? 0) - (b.day_number ?? 0)
/**
* Read a planDayGrid plan as the trip dialog shows it: which of the current
* days go and everything on them, counted the same way the delete day
* question counts a single day.
*/
export function tripRangeImpact(
plan: DayGridPlan,
days: Day[],
data: DayContentData,
options: { startMoved: boolean },
): TripRangeImpact {
const gone = new Set(plan.removed.map(r => r.id))
const removedDays = [...days]
.sort(byNumber)
.map((day, index) => ({ day, index }))
.filter(({ day }) => gone.has(day.id))
const dropsDatedDays = plan.removed.some(r => r.reason === 'overflow')
return {
removedDays,
startMoved: options.startMoved && dropsDatedDays,
content: contentOnDays(removedDays.map(r => r.day), data),
}
}
/**
* Whether the days that go hold anything a traveller would miss. Empty days
* come and go with every change of dates; asking about them would teach people
* to click the question away.
*/
export function hasVisibleContent(impact: TripRangeImpact): boolean {
const { places, notes, texts, bookings, stays } = impact.content
return places + notes + texts + bookings + stays.length > 0
}
@@ -6,7 +6,9 @@ import { tripsApi } from '../../../../src/api/client';
import { useAuthStore } from '../../../../src/store/authStore';
import { usePermissionsStore } from '../../../../src/store/permissionsStore';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { buildUser } from '../../../helpers/factories';
import { buildAssignment, buildDay, buildPlace, buildUser } from '../../../helpers/factories';
import { http, HttpResponse } from 'msw';
import { server } from '../../../helpers/msw/server';
import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel';
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared';
@@ -559,4 +561,96 @@ describe('MNewTripSheet', () => {
expect(onClose).toHaveBeenCalledTimes(2);
expect(onSave).not.toHaveBeenCalled();
});
// ── Shortening an existing trip: the same question as on the desktop ─────
/** The trip's days as the server has them: 1 to 8 May, a place on the last two. */
function seedDays(withContent = true) {
const days = Array.from({ length: 8 }, (_, i) => buildDay({
id: 500 + i,
trip_id: 42,
day_number: i + 1,
date: `2026-05-0${i + 1}`,
assignments: withContent && i >= 6 ? [buildAssignment({ day_id: 500 + i, place: buildPlace({ id: 600 + i }) })] : [],
}));
server.use(
http.get('/api/trips/:id/days', () => HttpResponse.json({ days })),
http.get('/api/trips/:id/reservations', () => HttpResponse.json({ reservations: [] })),
http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ accommodations: [] })),
);
}
async function shortenToSixDays(onSave: (data: TripCreateRequest) => Promise<void>, onClose = () => {}) {
render(<MNewTripSheet open trip={buildDashTrip()} onClose={onClose} onSave={onSave} />);
fireEvent.change(screen.getByLabelText('End Date'), { target: { value: '2026-05-06' } });
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
}
it('FE-MOB-NTSH-037: an earlier end opens a danger question that names the days and what is on them', async () => {
seedDays();
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
await shortenToSixDays(onSave);
const sheet = await screen.findByRole('dialog', { name: 'Remove days?' });
expect(sheet).toHaveTextContent('Saving the new dates removes these days:');
const list = screen.getByRole('list', { name: 'Remove days?' });
expect(list).toHaveTextContent('Thu, May 7');
expect(list).toHaveTextContent('Fri, May 8');
expect(list).toHaveTextContent('Planned places: 2');
// The phone has no shift step, so the booking hint is the default one.
expect(screen.getByRole('button', { name: 'Remove days and save' })).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('FE-MOB-NTSH-038: cancelling the question saves nothing and keeps the edit sheet', async () => {
seedDays();
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
const onClose = vi.fn();
await shortenToSixDays(onSave, onClose);
const sheet = await screen.findByRole('dialog', { name: 'Remove days?' });
const cancel = Array.from(sheet.querySelectorAll('button')).find(b => b.textContent === 'Cancel')!;
fireEvent.click(cancel);
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove days?' })).not.toBeInTheDocument());
expect(onSave).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByLabelText('End Date')).toHaveValue('2026-05-06');
});
it('FE-MOB-NTSH-039: confirming saves the new dates and closes the sheet', async () => {
seedDays();
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
const onClose = vi.fn();
await shortenToSixDays(onSave, onClose);
fireEvent.click(await screen.findByRole('button', { name: 'Remove days and save' }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ start_date: '2026-05-01', end_date: '2026-05-06' }));
expect(onSave.mock.calls[0][0]).not.toHaveProperty('date_shift_mode');
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('FE-MOB-NTSH-040: when only empty days go there is no question, the save goes through', async () => {
seedDays(false);
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
await shortenToSixDays(onSave);
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ end_date: '2026-05-06' })));
expect(screen.queryByRole('dialog', { name: 'Remove days?' })).not.toBeInTheDocument();
});
it('FE-MOB-NTSH-041: when the days cannot be read it still asks, in general terms', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
server.use(http.get('/api/trips/:id/days', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
await shortenToSixDays(onSave);
const sheet = await screen.findByRole('dialog', { name: 'Remove days?' });
expect(sheet).toHaveTextContent('The days of this trip could not be checked.');
expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
spy.mockRestore();
});
});