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
+1
View File
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'استخدم صورة من Unsplash بواسطة {photographer}',
'dashboard.titleRequired': 'العنوان مطلوب',
'dashboard.endDateError': 'يجب أن يكون تاريخ النهاية بعد البداية',
'dashboard.tripTooLong': 'لا يمكن أن تتجاوز مدة الرحلة {days} يومًا',
'dashboard.dateShiftTitle': 'تاريخ بداية جديد',
'dashboard.dateShiftIntro': 'لقد غيّرت موعد بداية هذه الرحلة. كيف تريد أن تتبع خططك التواريخ الجديدة؟',
'dashboard.dateShiftKeepBookings': 'إبقاء الحجوزات في تواريخها',
+1
View File
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Usar foto do Unsplash por {photographer}',
'dashboard.titleRequired': 'O título é obrigatório',
'dashboard.endDateError': 'A data final deve ser depois da inicial',
'dashboard.tripTooLong': 'Uma viagem pode ter no máximo {days} dias',
'dashboard.dateShiftTitle': 'Nova data de início',
'dashboard.dateShiftIntro': 'Você alterou o início desta viagem. Como seus planos devem acompanhar as novas datas?',
'dashboard.dateShiftKeepBookings': 'Manter reservas em suas datas',
+1
View File
@@ -75,6 +75,7 @@ const dashboard: TranslationStrings = {
'dashboard.coverRemoveError': 'Error en eliminar la imatge',
'dashboard.titleRequired': 'El títol és obligatori',
'dashboard.endDateError': "La data de fi ha de ser posterior a la d'inici",
'dashboard.tripTooLong': 'Un viatge pot durar com a màxim {days} dies',
'dashboard.greeting.morning': 'Bon dia,',
'dashboard.greeting.afternoon': 'Bona tarda,',
'dashboard.greeting.evening': 'Bona nit,',
+1
View File
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Použít fotku z Unsplash od {photographer}',
'dashboard.titleRequired': 'Název je povinný',
'dashboard.endDateError': 'Datum konce musí být po datu začátku',
'dashboard.tripTooLong': 'Cesta může trvat nejvýše {days} dní',
'dashboard.dateShiftTitle': 'Nové datum začátku',
'dashboard.dateShiftIntro': 'Změnili jste začátek této cesty. Jak se mají vaše plány přizpůsobit novým termínům?',
'dashboard.dateShiftKeepBookings': 'Ponechat rezervace v původních termínech',
+1
View File
@@ -88,6 +88,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Unsplash-Foto von {photographer} verwenden',
'dashboard.titleRequired': 'Titel ist erforderlich',
'dashboard.endDateError': 'Enddatum muss nach dem Startdatum liegen',
'dashboard.tripTooLong': 'Eine Reise kann höchstens {days} Tage umfassen',
'dashboard.dateShiftTitle': 'Neues Startdatum',
'dashboard.dateShiftIntro':
'Du hast den Beginn dieser Reise geändert. Wie sollen deine Pläne den neuen Daten folgen?',
+1
View File
@@ -101,6 +101,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Use Unsplash photo by {photographer}',
'dashboard.titleRequired': 'Title is required',
'dashboard.endDateError': 'End date must be after start date',
'dashboard.tripTooLong': 'A trip can span at most {days} days',
'dashboard.dateShiftTitle': 'New start date',
'dashboard.dateShiftIntro': 'You changed when this trip starts. How should your plans follow the new dates?',
'dashboard.dateShiftKeepBookings': 'Keep bookings on their dates',
+1
View File
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Usar foto de Unsplash de {photographer}',
'dashboard.titleRequired': 'El título es obligatorio',
'dashboard.endDateError': 'La fecha de fin debe ser posterior a la de inicio',
'dashboard.tripTooLong': 'Un viaje puede durar como máximo {days} días',
'dashboard.dateShiftTitle': 'Nueva fecha de inicio',
'dashboard.dateShiftIntro':
'Has cambiado cuándo empieza este viaje. ¿Cómo deben seguir tus planes las nuevas fechas?',
+1
View File
@@ -89,6 +89,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Utiliser la photo Unsplash de {photographer}',
'dashboard.titleRequired': 'Le titre est obligatoire',
'dashboard.endDateError': 'La date de fin doit être postérieure à la date de début',
'dashboard.tripTooLong': 'Un voyage ne peut pas dépasser {days} jours',
'dashboard.dateShiftTitle': 'Nouvelle date de début',
'dashboard.dateShiftIntro':
'Vous avez modifié la date de début de ce voyage. Comment vos plans doivent-ils suivre les nouvelles dates ?',
+1
View File
@@ -99,6 +99,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Χρήση φωτογραφίας Unsplash από {photographer}',
'dashboard.titleRequired': 'Ο τίτλος είναι υποχρεωτικός',
'dashboard.endDateError': 'Η ημερομηνία λήξης πρέπει να είναι μετά την ημερομηνία έναρξης',
'dashboard.tripTooLong': 'Ένα ταξίδι μπορεί να διαρκεί το πολύ {days} ημέρες',
'dashboard.dateShiftTitle': 'Νέα ημερομηνία έναρξης',
'dashboard.dateShiftIntro':
'Αλλάξατε την ημερομηνία έναρξης αυτού του ταξιδιού. Πώς θέλετε να ακολουθήσουν τα σχέδιά σας τις νέες ημερομηνίες;',
+1
View File
@@ -89,6 +89,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': '{photographer} Unsplash-fotójának használata',
'dashboard.titleRequired': 'A cím megadása kötelező',
'dashboard.endDateError': 'A záró dátumnak a kezdő dátum után kell lennie',
'dashboard.tripTooLong': 'Egy utazás legfeljebb {days} napos lehet',
'dashboard.dateShiftTitle': 'Új kezdő dátum',
'dashboard.dateShiftIntro': 'Megváltoztattad az út kezdetét. Hogyan kövessék a terveid az új dátumokat?',
'dashboard.dateShiftKeepBookings': 'A foglalások maradjanak a saját dátumaikon',
+1
View File
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Gunakan foto Unsplash oleh {photographer}',
'dashboard.titleRequired': 'Judul wajib diisi',
'dashboard.endDateError': 'Tanggal selesai harus setelah tanggal mulai',
'dashboard.tripTooLong': 'Perjalanan paling lama {days} hari',
'dashboard.dateShiftTitle': 'Tanggal mulai baru',
'dashboard.dateShiftIntro':
'Kamu mengubah tanggal mulai perjalanan ini. Bagaimana rencanamu harus mengikuti tanggal baru?',
+1
View File
@@ -89,6 +89,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Usa foto Unsplash di {photographer}',
'dashboard.titleRequired': 'Il titolo è obbligatorio',
'dashboard.endDateError': 'La data di fine deve essere successiva alla data di inizio',
'dashboard.tripTooLong': 'Un viaggio può durare al massimo {days} giorni',
'dashboard.dateShiftTitle': 'Nuova data di inizio',
'dashboard.dateShiftIntro':
'Hai modificato la data di inizio di questo viaggio. Come devono adattarsi i tuoi piani alle nuove date?',
+1
View File
@@ -102,6 +102,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': '{photographer}さんのUnsplash写真を使用',
'dashboard.titleRequired': 'タイトルは必須です',
'dashboard.endDateError': '終了日は開始日より後にしてください',
'dashboard.tripTooLong': '旅行の期間は最大{days}日までです',
'dashboard.dateShiftTitle': '新しい開始日',
'dashboard.dateShiftIntro': '旅行の開始日が変更されました。予定を新しい日程にどのように合わせますか?',
'dashboard.dateShiftKeepBookings': '予約を元の日付のままにする',
+1
View File
@@ -102,6 +102,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': '{photographer}님의 Unsplash 사진 사용',
'dashboard.titleRequired': '제목을 입력하세요',
'dashboard.endDateError': '종료일은 시작일 이후여야 합니다',
'dashboard.tripTooLong': '여행 기간은 최대 {days}일까지입니다',
'dashboard.dateShiftTitle': '새 시작일',
'dashboard.dateShiftIntro': '여행 시작일이 변경되었습니다. 계획을 새 날짜에 어떻게 맞출까요?',
'dashboard.dateShiftKeepBookings': '예약은 원래 날짜에 유지',
+1
View File
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Unsplash-foto van {photographer} gebruiken',
'dashboard.titleRequired': 'Titel is verplicht',
'dashboard.endDateError': 'Einddatum moet na de startdatum liggen',
'dashboard.tripTooLong': 'Een reis kan maximaal {days} dagen duren',
'dashboard.dateShiftTitle': 'Nieuwe startdatum',
'dashboard.dateShiftIntro':
'Je hebt gewijzigd wanneer deze reis begint. Hoe moeten je plannen de nieuwe datums volgen?',
+1
View File
@@ -83,6 +83,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Użyj zdjęcia z Unsplash autorstwa {photographer}',
'dashboard.titleRequired': 'Nazwa podróży jest wymagana',
'dashboard.endDateError': 'Data zakończenia musi być po dacie rozpoczęcia',
'dashboard.tripTooLong': 'Podróż może trwać maksymalnie {days} dni',
'dashboard.dateShiftTitle': 'Nowa data rozpoczęcia',
'dashboard.dateShiftIntro':
'Data rozpoczęcia podróży została zmieniona. Jak plany mają dostosować się do nowych dat?',
+1
View File
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Использовать фото Unsplash от {photographer}',
'dashboard.titleRequired': 'Название обязательно',
'dashboard.endDateError': 'Дата окончания должна быть позже даты начала',
'dashboard.tripTooLong': 'Поездка может длиться не более {days} дней',
'dashboard.dateShiftTitle': 'Новая дата начала',
'dashboard.dateShiftIntro': 'Вы изменили дату начала поездки. Как перенести ваши планы на новые даты?',
'dashboard.dateShiftKeepBookings': 'Оставить бронирования на своих датах',
+1
View File
@@ -101,6 +101,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Använd Unsplash-foto av {photographer}',
'dashboard.titleRequired': 'Titel är obligatoriskt',
'dashboard.endDateError': 'Slutdatumet måste ligga efter startdatumet',
'dashboard.tripTooLong': 'En resa kan vara högst {days} dagar',
'dashboard.dateShiftTitle': 'Nytt startdatum',
'dashboard.dateShiftIntro': 'Du har ändrat när resan börjar. Hur ska dina planer följa de nya datumen?',
'dashboard.dateShiftKeepBookings': 'Behåll bokningar på sina datum',
+1
View File
@@ -101,6 +101,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': '{photographer} tarafından çekilen Unsplash fotoğrafını kullan',
'dashboard.titleRequired': 'Başlık gerekli',
'dashboard.endDateError': 'Bitiş tarihi başlangıçtan sonra olmalı',
'dashboard.tripTooLong': 'Bir seyahat en fazla {days} gün sürebilir',
'dashboard.dateShiftTitle': 'Yeni başlangıç tarihi',
'dashboard.dateShiftIntro':
'Bu seyahatin başlangıç tarihini değiştirdiniz. Planlarınız yeni tarihlere nasıl uyum sağlasın?',
+1
View File
@@ -103,6 +103,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Використати фото Unsplash від {photographer}',
'dashboard.titleRequired': "Назва обов'язкова",
'dashboard.endDateError': 'Дата закінчення повинна бути пізніше дати початку',
'dashboard.tripTooLong': 'Подорож може тривати не більше {days} днів',
'dashboard.dateShiftTitle': 'Нова дата початку',
'dashboard.dateShiftIntro': 'Ви змінили дату початку поїздки. Як ваші плани мають перейти на нові дати?',
'dashboard.dateShiftKeepBookings': 'Залишити бронювання на своїх датах',
+1
View File
@@ -95,6 +95,7 @@ const dashboard: TranslationStrings = {
'dashboard.coverRemoveError': 'Không thể xóa',
'dashboard.titleRequired': 'Tiêu đề là bắt buộc',
'dashboard.endDateError': 'Ngày kết thúc phải sau ngày bắt đầu',
'dashboard.tripTooLong': 'Một chuyến đi có thể kéo dài tối đa {days} ngày',
'dashboard.dateShiftTitle': 'Ngày bắt đầu mới',
'dashboard.dateShiftIntro':
'Bạn đã thay đổi thời điểm bắt đầu chuyến đi. Các kế hoạch của bạn nên theo ngày mới như thế nào?',
+1
View File
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': '使用 {photographer} 的 Unsplash 照片',
'dashboard.titleRequired': '標題為必填項',
'dashboard.endDateError': '結束日期必須晚於開始日期',
'dashboard.tripTooLong': '行程最長為 {days} 天',
'dashboard.dateShiftTitle': '新的開始日期',
'dashboard.dateShiftIntro': '你變更了旅行的開始時間。你的計劃要如何配合新日期?',
'dashboard.dateShiftKeepBookings': '預訂保留在原日期',
+1
View File
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': '使用 {photographer} 的 Unsplash 照片',
'dashboard.titleRequired': '标题为必填项',
'dashboard.endDateError': '结束日期必须晚于开始日期',
'dashboard.tripTooLong': '行程最长为 {days} 天',
'dashboard.dateShiftTitle': '新的开始日期',
'dashboard.dateShiftIntro': '您更改了旅行的开始时间。您的计划应如何适应新的日期?',
'dashboard.dateShiftKeepBookings': '保留预订的原定日期',
+22
View File
@@ -1,4 +1,6 @@
import {
MAX_TRIP_DAYS,
tripSpanDays,
tripCreateRequestSchema,
tripUpdateRequestSchema,
tripAddMemberRequestSchema,
@@ -21,6 +23,26 @@ describe('tripCreateRequestSchema', () => {
});
});
describe('MAX_TRIP_DAYS', () => {
it('lets a trip run past a year and keeps day numbers to three digits (#2403)', () => {
expect(MAX_TRIP_DAYS).toBeGreaterThan(366);
expect(MAX_TRIP_DAYS).toBeLessThan(1000);
});
});
describe('tripSpanDays', () => {
it('counts both ends, across a leap day and a year boundary', () => {
expect(tripSpanDays('2026-07-01', '2026-07-01')).toBe(1);
expect(tripSpanDays('2026-07-01', '2026-07-07')).toBe(7);
expect(tripSpanDays('2024-02-28', '2024-03-01')).toBe(3);
expect(tripSpanDays('2025-01-26', '2026-01-28')).toBe(368);
});
it('goes below one when the end precedes the start', () => {
expect(tripSpanDays('2026-07-07', '2026-07-01')).toBe(-5);
});
});
describe('tripUpdateRequestSchema', () => {
it('is fully partial and accepts is_archived + cover_image', () => {
expect(tripUpdateRequestSchema.safeParse({}).success).toBe(true);
+19
View File
@@ -13,6 +13,25 @@ import { z } from 'zod';
* permission checks + audit logging. Trip rows are wide, so responses stay open.
*/
/**
* The most days a trip can have, whether from its date range or from the
* `day_count` of a dateless trip. A range longer than this is refused rather
* than cut short: a trip whose end date lies past its last day is exactly the
* kind of half-state #2403 reported, and a mistyped year is easier to spot as
* an error than as a few hundred empty days. Three digits keep every day badge
* in the planner intact.
*/
export const MAX_TRIP_DAYS = 999;
/** Calendar days in an inclusive YYYY-MM-DD range, counted the same way on both sides of the wire. */
export function tripSpanDays(startDate: string, endDate: string): number {
const utcDay = (date: string) => {
const [y = NaN, m = NaN, d = NaN] = date.split('-').map(Number);
return Date.UTC(y, m - 1, d);
};
return Math.floor((utcDay(endDate) - utcDay(startDate)) / 86400000) + 1;
}
/**
* Trip entity as returned by the trip list / get / create / update endpoints
* (server/src/services/tripService.ts -> TRIP_SELECT). Columns of the `trips`