fix(trips): let a trip run past 365 days and refuse a longer range instead of clipping it

A trip longer than a year stopped at day 365: generateDays capped the day
rows at MAX_TRIP_DAYS while the trip kept its full end date, so the last
days had a date but no day to plan on, in the day list, the transport and
booking dialogs and the phone's date pickers (#2403).

The limit is MAX_TRIP_DAYS in @trek/shared now, 999 days, shared by the
server, both trip forms and the MCP tools, and a range past it is refused
rather than cut short: 400 on REST, an error result on MCP, bad params on
the plugin RPC, and a message in the forms before anything is sent. The
check runs before the trip row and the budget are touched, also catches a
start date moved past the stored end on its own, and holds a day_count
rebuild to the same bound. A trip that already carries a longer range can
still be renamed. The MCP-only 90-day ceiling on create_trip goes with it;
it clipped dated trips the same way.

Closes #2403
This commit is contained in:
Maurice
2026-09-20 12:08:50 +02:00
parent 44c92052a3
commit c8d4ed896e
42 changed files with 409 additions and 70 deletions
@@ -11,6 +11,7 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import type { Trip } from '../../types';
import { MAX_TRIP_DAYS } from '@trek/shared';
import TripFormModal from './TripFormModal';
const defaultProps = {
@@ -321,7 +322,7 @@ describe('TripFormModal', () => {
it('FE-COMP-TRIPFORM-029: clearing the day count leaves the field empty (no snap to 1)', () => {
render(<TripFormModal {...defaultProps} trip={null} />);
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement;
expect(dayInput).toBeInTheDocument();
expect(dayInput.value).toBe('7');
fireEvent.change(dayInput, { target: { value: '' } });
@@ -333,7 +334,7 @@ describe('TripFormModal', () => {
const onSave = vi.fn();
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'No-date Trip');
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement;
fireEvent.change(dayInput, { target: { value: '' } });
const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!;
await user.click(submitBtn.closest('button')!);
@@ -534,7 +535,7 @@ describe('TripFormModal', () => {
expect(screen.getByPlaceholderText(/Summer in Japan/i)).toHaveValue('');
expect(screen.getByText(/^EUR/)).toBeInTheDocument();
// No dates -> the day-count field appears and falls back to 7.
expect(document.querySelector('input[max="365"]')).toHaveValue('7');
expect(document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`)).toHaveValue('7');
});
// ── Create follow-ups: members and cover ──────────────────────────────────
@@ -921,7 +922,7 @@ describe('TripFormModal', () => {
fireEvent.keyDown(input, { key: 'Enter' });
// The day-count field disappears once the trip is dated.
await waitFor(() => expect(document.querySelector('input[max="365"]')).toBeNull());
await waitFor(() => expect(document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`)).toBeNull());
await submitNewTrip(user);
await waitFor(() => expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ start_date: '2026-04-10', end_date: '2026-04-10' })
@@ -951,18 +952,18 @@ describe('TripFormModal', () => {
));
});
it('FE-COMP-TRIPFORM-067: the day count is clamped to the 1..365 range', async () => {
it('FE-COMP-TRIPFORM-067: the day count is clamped to the 1..MAX_TRIP_DAYS range', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
fireEvent.change(dayInput, { target: { value: '400' } });
expect(dayInput.value).toBe('365');
const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement;
fireEvent.change(dayInput, { target: { value: String(MAX_TRIP_DAYS + 1) } });
expect(dayInput.value).toBe(String(MAX_TRIP_DAYS));
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Long Trip');
await submitNewTrip(user);
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ day_count: 365 })));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ day_count: MAX_TRIP_DAYS })));
});
it('FE-COMP-TRIPFORM-068: a currency without a known symbol is labelled with its code', () => {
@@ -1249,4 +1250,44 @@ describe('TripFormModal', () => {
await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:first'));
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: original });
});
it('FE-COMP-TRIPFORM-085: a trip longer than a year is saved with its full range (#2403)', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
const trip = buildTrip({ id: 1, title: 'Gap year', start_date: '2025-01-26', end_date: '2026-01-28' });
render(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
await user.click(screen.getByRole('button', { name: /Update/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ start_date: '2025-01-26', end_date: '2026-01-28' })
));
});
it('FE-COMP-TRIPFORM-086: a range past MAX_TRIP_DAYS is refused before anything is sent', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
const trip = buildTrip({ id: 1, title: 'Week', start_date: '2026-01-01', end_date: '2026-01-07' });
render(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[1]);
const endInput = screen.getByPlaceholderText('DD.MM.YYYY');
fireEvent.change(endInput, { target: { value: '2036-01-01' } });
fireEvent.keyDown(endInput, { key: 'Enter' });
await user.click(screen.getByRole('button', { name: /Update/i }));
await screen.findByText(`A trip can span at most ${MAX_TRIP_DAYS} days`);
expect(onSave).not.toHaveBeenCalled();
});
it('FE-COMP-TRIPFORM-087: a trip stored with a longer range can still be renamed', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
const trip = buildTrip({ id: 1, title: 'Legacy', start_date: '2020-01-01', end_date: '2030-01-01' });
render(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
await user.clear(screen.getByPlaceholderText(/Summer in Japan/i));
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Renamed');
await user.click(screen.getByRole('button', { name: /Update/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Renamed', start_date: '2020-01-01', end_date: '2030-01-01' })
));
});
});
+11 -6
View File
@@ -11,7 +11,7 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { normalizeImageFile } from '../../utils/convertHeic'
import { getApiErrorMessage, type Trip } from '../../types'
import type { TripCreateRequest } from '@trek/shared'
import { MAX_TRIP_DAYS, tripSpanDays, type TripCreateRequest } from '@trek/shared'
import { NumericInput } from '../shared/NumericInput'
import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants'
@@ -148,12 +148,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
e.preventDefault()
setError('')
if (!formData.title.trim()) { setError(t('dashboard.titleRequired')); return }
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
setError(t('dashboard.endDateError')); return
if (formData.start_date && formData.end_date) {
const span = tripSpanDays(formData.start_date, formData.end_date)
if (span < 1) { setError(t('dashboard.endDateError')); return }
// Only a range being set is held to the limit, as on the server: a trip that
// already carries a longer one can still be renamed.
const datesTouched = !trip || formData.start_date !== (trip.start_date || '') || formData.end_date !== (trip.end_date || '')
if (datesTouched && span > MAX_TRIP_DAYS) { setError(t('dashboard.tripTooLong', { days: MAX_TRIP_DAYS })); return }
}
if (!formData.start_date && !formData.end_date) {
const dc = Number(formData.day_count)
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > 365) {
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > MAX_TRIP_DAYS) {
setError(t('dashboard.dayCountRequired')); return
}
}
@@ -557,11 +562,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<label className={labelCls}>
{t('dashboard.dayCount')}
</label>
<NumericInput min={1} max={365} value={formData.day_count}
<NumericInput min={1} max={MAX_TRIP_DAYS} value={formData.day_count}
onValueChange={raw => {
if (raw === '') { update('day_count', ''); return }
const n = Math.floor(Number(raw))
if (Number.isFinite(n)) update('day_count', Math.min(365, Math.max(1, n)))
if (Number.isFinite(n)) update('day_count', Math.min(MAX_TRIP_DAYS, Math.max(1, n)))
}}
className={inputCls} />
<p className="text-caption text-content-faint mt-1.5">{t('dashboard.dayCountHint')}</p>
@@ -11,7 +11,7 @@ import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicke
import CustomSelect from '../../../components/shared/CustomSelect'
import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.constants'
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
import type { Trip, TripCreateRequest } from '@trek/shared'
import { MAX_TRIP_DAYS, tripSpanDays, type Trip, type TripCreateRequest } from '@trek/shared'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MListRow from '../../components/MListRow'
@@ -112,8 +112,11 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
const handleSave = async () => {
setError('')
if (!title.trim()) { setError(t('dashboard.titleRequired')); return }
if (startDate && endDate && new Date(endDate) < new Date(startDate)) {
setError(t('dashboard.endDateError')); return
if (startDate && endDate) {
const span = tripSpanDays(startDate, endDate)
if (span < 1) { setError(t('dashboard.endDateError')); return }
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 }
}
setIsSaving(true)
try {
@@ -8,7 +8,7 @@ import { usePermissionsStore } from '../../../../src/store/permissionsStore';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { buildUser } from '../../../helpers/factories';
import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel';
import type { Trip, TripCreateRequest } from '@trek/shared';
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared';
interface CoverSearchPhoto {
id: string
@@ -136,6 +136,20 @@ describe('MNewTripSheet', () => {
expect(onSave).not.toHaveBeenCalled();
});
it('FE-MOB-NTSH-036: a range past MAX_TRIP_DAYS blocks the save (#2403)', () => {
const onSave = vi.fn(async () => undefined);
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={onSave} />);
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Decade' } });
fireEvent.change(screen.getByLabelText('Start Date'), { target: { value: '2026-01-01' } });
fireEvent.change(screen.getByLabelText('End Date'), { target: { value: '2036-01-01' } });
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }));
expect(screen.getByText(`A trip can span at most ${MAX_TRIP_DAYS} days`)).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('FE-MOB-NTSH-005: a dateless create asks for the 7 default days and closes', async () => {
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
const onClose = vi.fn();