fix(db): give clipped trips their days back and let road-trip day numbers follow the trip limit

Two boot migrations. The first appends the missing dated days to a trip
the old 365-day cap clipped, only where the dated days still run unbroken
from the start date, so a trip that was re-dated by hand or lost a day in
the middle is left alone; dateless days that hold content stay behind the
dated ones as generateDays keeps them. The second rebuilds
roadtrip_day_boundaries without the CHECK that held day_number to 366,
which would have refused a boundary on a later day of a longer trip.

The road-trip contracts follow the same limit: the day boundary and
corridor schemas, the set_day_boundary tool and the day window's runaway
guard read MAX_TRIP_DAYS instead of their own 365 or 366, and the
tooLong message carries the number as a placeholder in all 23 locales.
This commit is contained in:
Maurice
2026-09-20 12:09:00 +02:00
parent c8d4ed896e
commit 62cccf32f3
34 changed files with 295 additions and 35 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': 'يلزم توفر جميع المسارات الرابطة لحساب الاستراحات اليومية.',
'roadtrip.window.conflict':
'يتعارض وقت ثابت مع القيادة والاستراحات. تبقى الأوقات اليدوية كما هي؛ تم إيقاف التخطيط التلقائي مؤقتًا.',
'roadtrip.window.tooLong': 'تتطلب هذه الأوقات أكثر من 365 يومًا إضافيًا. اختر فترة يومية أطول.',
'roadtrip.window.tooLong': 'تتطلب هذه الأوقات أكثر من {days} يومًا إضافيًا. اختر فترة يومية أطول.',
'roadtrip.mode.roadtrip': 'رحلة برية',
'roadtrip.mode.days': 'الأيام',
'roadtrip.mode.label': 'عرض الخطة',
+1 -1
View File
@@ -55,7 +55,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': 'Todas as rotas de conexão são necessárias para calcular as pausas diárias.',
'roadtrip.window.conflict':
'Um horário fixo conflita com o trajeto e as pausas. Os horários manuais são mantidos; o planejamento automático está pausado.',
'roadtrip.window.tooLong': 'Esses horários exigiriam mais de 365 dias extras. Escolha um período diário maior.',
'roadtrip.window.tooLong': 'Esses horários exigiriam mais de {days} dias extras. Escolha um período diário maior.',
'roadtrip.mode.roadtrip': 'Viagem de carro',
'roadtrip.mode.days': 'Dias',
'roadtrip.mode.label': 'Visão do plano',
+1 -1
View File
@@ -56,7 +56,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.conflict':
'Una hora fixa entra en conflicte amb el trajecte i les pauses. Es mantenen les hores manuals; la planificació automàtica està en pausa.',
'roadtrip.window.tooLong':
'Aquests horaris requereixen més de 365 dies addicionals. Tria una franja diària més llarga.',
'Aquests horaris requereixen més de {days} dies addicionals. Tria una franja diària més llarga.',
'roadtrip.mode.roadtrip': 'Viatge per carretera',
'roadtrip.mode.days': 'Dies',
'roadtrip.mode.label': 'Vista del pla',
+1 -1
View File
@@ -55,7 +55,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': 'Denní přestávky lze spočítat až po získání všech navazujících tras.',
'roadtrip.window.conflict':
'Pevný čas je v rozporu s jízdou a přestávkami. Ruční časy zůstávají zachovány; automatické plánování je pozastaveno.',
'roadtrip.window.tooLong': 'Tyto časy vyžadují více než 365 dalších dní. Zvolte delší denní interval.',
'roadtrip.window.tooLong': 'Tyto časy vyžadují více než {days} dalších dní. Zvolte delší denní interval.',
'roadtrip.mode.roadtrip': 'Cesta autem',
'roadtrip.mode.days': 'Dny',
'roadtrip.mode.label': 'Zobrazení plánu',
+1 -1
View File
@@ -54,7 +54,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.conflict':
'Eine feste Uhrzeit passt nicht zu Fahrzeit und Tagespausen. Manuelle Zeiten bleiben unverändert; die automatische Aufteilung pausiert.',
'roadtrip.window.tooLong':
'Diese Reisezeiten erfordern mehr als 365 zusätzliche Tage. Wähle ein längeres Tagesfenster.',
'Diese Reisezeiten erfordern mehr als {days} zusätzliche Tage. Wähle ein längeres Tagesfenster.',
'roadtrip.mode.roadtrip': 'Roadtrip',
'roadtrip.mode.days': 'Tage',
'roadtrip.mode.label': 'Plan-Ansicht',
+2 -1
View File
@@ -53,7 +53,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': 'Daily breaks cannot be calculated until all connecting routes are available.',
'roadtrip.window.conflict':
'A fixed time conflicts with the drive and daily breaks. Manual times are unchanged; automatic scheduling is paused.',
'roadtrip.window.tooLong': 'These travel times would require more than 365 extra days. Choose a longer daily window.',
'roadtrip.window.tooLong':
'These travel times would require more than {days} extra days. Choose a longer daily window.',
'roadtrip.mode.roadtrip': 'Road trip',
'roadtrip.mode.days': 'Days',
'roadtrip.mode.label': 'Plan view',
+1 -1
View File
@@ -56,7 +56,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': 'Las pausas diarias necesitan todas las rutas de conexión para calcularse.',
'roadtrip.window.conflict':
'Una hora fija entra en conflicto con el trayecto y las pausas. Las horas manuales se conservan; la planificación automática está en pausa.',
'roadtrip.window.tooLong': 'Estos horarios requieren más de 365 días adicionales. Amplía el horario diario.',
'roadtrip.window.tooLong': 'Estos horarios requieren más de {days} días adicionales. Amplía el horario diario.',
'roadtrip.mode.roadtrip': 'Viaje por carretera',
'roadtrip.mode.days': 'Días',
'roadtrip.mode.label': 'Vista del plan',
+1 -1
View File
@@ -57,7 +57,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.conflict':
'Une heure fixe est incompatible avec le trajet et les pauses. Les heures manuelles sont conservées ; la planification automatique est suspendue.',
'roadtrip.window.tooLong':
'Ces horaires nécessitent plus de 365 jours supplémentaires. Élargissez la plage horaire quotidienne.',
'Ces horaires nécessitent plus de {days} jours supplémentaires. Élargissez la plage horaire quotidienne.',
'roadtrip.mode.roadtrip': 'Road trip',
'roadtrip.mode.days': 'Jours',
'roadtrip.mode.label': 'Vue du plan',
+1 -1
View File
@@ -58,7 +58,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.conflict':
'Μια σταθερή ώρα συγκρούεται με την οδήγηση και τις παύσεις. Οι χειροκίνητες ώρες διατηρούνται· ο αυτόματος σχεδιασμός έχει παύσει.',
'roadtrip.window.tooLong':
'Αυτές οι ώρες απαιτούν πάνω από 365 επιπλέον ημέρες. Επιλέξτε μεγαλύτερο ημερήσιο διάστημα.',
'Αυτές οι ώρες απαιτούν πάνω από {days} επιπλέον ημέρες. Επιλέξτε μεγαλύτερο ημερήσιο διάστημα.',
'roadtrip.mode.roadtrip': 'Οδικό ταξίδι',
'roadtrip.mode.days': 'Ημέρες',
'roadtrip.mode.label': 'Προβολή σχεδίου',
+1 -1
View File
@@ -56,7 +56,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.conflict':
'Egy rögzített időpont ütközik az utazással és a szünetekkel. A kézi időpontok megmaradnak; az automatikus tervezés szünetel.',
'roadtrip.window.tooLong':
'Ezek az időpontok több mint 365 további napot igényelnének. Válassz hosszabb napi időszakot.',
'Ezek az időpontok több mint {days} további napot igényelnének. Válassz hosszabb napi időszakot.',
'roadtrip.mode.roadtrip': 'Autós út',
'roadtrip.mode.days': 'Napok',
'roadtrip.mode.label': 'Terv nézet',
+1 -1
View File
@@ -56,7 +56,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.conflict':
'Waktu tetap bertentangan dengan perjalanan dan jeda. Waktu manual tidak berubah; perencanaan otomatis dijeda.',
'roadtrip.window.tooLong':
'Waktu ini memerlukan lebih dari 365 hari tambahan. Pilih rentang waktu harian yang lebih panjang.',
'Waktu ini memerlukan lebih dari {days} hari tambahan. Pilih rentang waktu harian yang lebih panjang.',
'roadtrip.mode.roadtrip': 'Perjalanan darat',
'roadtrip.mode.days': 'Hari',
'roadtrip.mode.label': 'Tampilan rencana',
+1 -1
View File
@@ -56,7 +56,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.conflict':
'Un orario fisso è incompatibile con il tragitto e le pause. Gli orari manuali restano invariati; la pianificazione automatica è sospesa.',
'roadtrip.window.tooLong':
'Questi orari richiederebbero oltre 365 giorni aggiuntivi. Scegli un intervallo giornaliero più lungo.',
'Questi orari richiederebbero oltre {days} giorni aggiuntivi. Scegli un intervallo giornaliero più lungo.',
'roadtrip.mode.roadtrip': 'Viaggio su strada',
'roadtrip.mode.days': 'Giorni',
'roadtrip.mode.label': 'Vista del piano',
+1 -1
View File
@@ -56,7 +56,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': '毎日の休憩を計算するには、すべての接続ルートが必要です。',
'roadtrip.window.conflict':
'固定時刻が移動や休憩と両立しません。手動の時刻は保持され、自動計画は一時停止しています。',
'roadtrip.window.tooLong': 'この設定では365日を超える追加日数が必要です。一日の移動時間を長くしてください。',
'roadtrip.window.tooLong': 'この設定では{days}日を超える追加日数が必要です。一日の移動時間を長くしてください。',
'roadtrip.mode.roadtrip': 'ロードトリップ',
'roadtrip.mode.days': '日ごと',
'roadtrip.mode.label': 'プラン表示',
+1 -1
View File
@@ -55,7 +55,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': '매일 휴식을 계산하려면 모든 연결 경로가 필요합니다.',
'roadtrip.window.conflict':
'고정 시간이 이동 및 휴식과 충돌합니다. 수동 시간은 유지되며 자동 계획이 일시 중지됩니다.',
'roadtrip.window.tooLong': '이 시간 설정에는 365일이 넘는 추가 날짜가 필요합니다. 일일 이동 시간을 늘려 주세요.',
'roadtrip.window.tooLong': '이 시간 설정에는 {days}일이 넘는 추가 날짜가 필요합니다. 일일 이동 시간을 늘려 주세요.',
'roadtrip.mode.roadtrip': '로드트립',
'roadtrip.mode.days': '일자별',
'roadtrip.mode.label': '계획 보기',
+1 -1
View File
@@ -56,7 +56,7 @@ const roadtrip: TranslationStrings = {
'Dagelijkse pauzes kunnen pas worden berekend als alle verbindingsroutes beschikbaar zijn.',
'roadtrip.window.conflict':
'Een vaste tijd past niet bij de rit en pauzes. Handmatige tijden blijven behouden; automatische planning is gepauzeerd.',
'roadtrip.window.tooLong': 'Deze tijden vragen meer dan 365 extra dagen. Kies een langer dagelijks tijdvak.',
'roadtrip.window.tooLong': 'Deze tijden vragen meer dan {days} extra dagen. Kies een langer dagelijks tijdvak.',
'roadtrip.mode.roadtrip': 'Roadtrip',
'roadtrip.mode.days': 'Dagen',
'roadtrip.mode.label': 'Planweergave',
+1 -1
View File
@@ -55,7 +55,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': 'Obliczenie codziennych przerw wymaga wszystkich tras łączących.',
'roadtrip.window.conflict':
'Stała godzina koliduje z jazdą i przerwami. Ręczne godziny pozostają bez zmian; automatyczne planowanie jest wstrzymane.',
'roadtrip.window.tooLong': 'Te godziny wymagają ponad 365 dodatkowych dni. Wybierz dłuższy dzienny przedział.',
'roadtrip.window.tooLong': 'Te godziny wymagają ponad {days} dodatkowych dni. Wybierz dłuższy dzienny przedział.',
'roadtrip.mode.roadtrip': 'Podróż samochodowa',
'roadtrip.mode.days': 'Dni',
'roadtrip.mode.label': 'Widok planu',
+1 -1
View File
@@ -56,7 +56,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.conflict':
'Фиксированное время противоречит поездке и перерывам. Ручные значения сохранены; автоматическое планирование приостановлено.',
'roadtrip.window.tooLong':
'При этих настройках потребуется более 365 дополнительных дней. Увеличьте ежедневный интервал.',
'При этих настройках потребуется более {days} дополнительных дней. Увеличьте ежедневный интервал.',
'roadtrip.mode.roadtrip': 'Автопутешествие',
'roadtrip.mode.days': 'Дни',
'roadtrip.mode.label': 'Вид плана',
+1 -1
View File
@@ -54,7 +54,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': 'Alla anslutande rutter behövs för att beräkna dagliga pauser.',
'roadtrip.window.conflict':
'En fast tid krockar med körningen och pauserna. Manuella tider behålls; automatisk planering är pausad.',
'roadtrip.window.tooLong': 'Dessa tider kräver fler än 365 extra dagar. Välj ett längre dagligt tidsintervall.',
'roadtrip.window.tooLong': 'Dessa tider kräver fler än {days} extra dagar. Välj ett längre dagligt tidsintervall.',
'roadtrip.mode.roadtrip': 'Bilresa',
'roadtrip.mode.days': 'Dagar',
'roadtrip.mode.label': 'Planvy',
+1 -1
View File
@@ -56,7 +56,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': 'Günlük molaları hesaplamak için tüm bağlantı rotaları gereklidir.',
'roadtrip.window.conflict':
'Sabit bir saat sürüş ve molalarla çakışıyor. Elle girilen saatler korunur; otomatik planlama duraklatıldı.',
'roadtrip.window.tooLong': 'Bu saatler 365 günden fazla ek süre gerektiriyor. Daha uzun bir günlük aralık seçin.',
'roadtrip.window.tooLong': 'Bu saatler {days} günden fazla ek süre gerektiriyor. Daha uzun bir günlük aralık seçin.',
'roadtrip.mode.roadtrip': 'Yol gezisi',
'roadtrip.mode.days': 'Günler',
'roadtrip.mode.label': 'Plan görünümü',
+1 -1
View File
@@ -55,7 +55,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': 'Для розрахунку щоденних перерв потрібні всі сполучні маршрути.',
'roadtrip.window.conflict':
'Фіксований час суперечить поїздці та перервам. Ручні значення збережено; автоматичне планування призупинено.',
'roadtrip.window.tooLong': 'За цих налаштувань потрібно понад 365 додаткових днів. Збільште щоденний інтервал.',
'roadtrip.window.tooLong': 'За цих налаштувань потрібно понад {days} додаткових днів. Збільште щоденний інтервал.',
'roadtrip.mode.roadtrip': 'Автоподорож',
'roadtrip.mode.days': 'Дні',
'roadtrip.mode.label': 'Вигляд плану',
+1 -1
View File
@@ -55,7 +55,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.incomplete': 'Cần có tất cả tuyến đường nối để tính thời gian nghỉ hằng ngày.',
'roadtrip.window.conflict':
'Một giờ cố định xung đột với việc lái xe và nghỉ. Giờ thủ công được giữ nguyên; lập lịch tự động tạm dừng.',
'roadtrip.window.tooLong': 'Các giờ này cần hơn 365 ngày bổ sung. Hãy chọn khoảng thời gian mỗi ngày dài hơn.',
'roadtrip.window.tooLong': 'Các giờ này cần hơn {days} ngày bổ sung. Hãy chọn khoảng thời gian mỗi ngày dài hơn.',
'roadtrip.mode.roadtrip': 'Chuyến đi đường bộ',
'roadtrip.mode.days': 'Ngày',
'roadtrip.mode.label': 'Chế độ xem kế hoạch',
+1 -1
View File
@@ -49,7 +49,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.showPoint': '在地圖上顯示',
'roadtrip.window.incomplete': '所有連接路線可用後才能計算每日休息。',
'roadtrip.window.conflict': '固定時間與行車和休息安排衝突。手動時間保持不變,自動規劃已暫停。',
'roadtrip.window.tooLong': '這些時間需要超過365個額外天數。請延長每日出行時段。',
'roadtrip.window.tooLong': '這些時間需要超過{days}個額外天數。請延長每日出行時段。',
'roadtrip.mode.roadtrip': '公路旅行',
'roadtrip.mode.days': '依日',
'roadtrip.mode.label': '計畫檢視',
+1 -1
View File
@@ -49,7 +49,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.window.showPoint': '在地图上显示',
'roadtrip.window.incomplete': '所有连接路线可用后才能计算每日休息。',
'roadtrip.window.conflict': '固定时间与行车和休息安排冲突。手动时间保持不变,自动规划已暂停。',
'roadtrip.window.tooLong': '这些时间需要超过365个额外天数。请延长每日出行时段。',
'roadtrip.window.tooLong': '这些时间需要超过{days}个额外天数。请延长每日出行时段。',
'roadtrip.mode.roadtrip': '公路旅行',
'roadtrip.mode.days': '按天',
'roadtrip.mode.label': '计划视图',
+3 -1
View File
@@ -1,8 +1,10 @@
import { MAX_TRIP_DAYS } from '../trip/trip.schema';
import { z } from 'zod';
export const roadtripDayBoundarySchema = z
.object({
day_number: z.number().int().min(1).max(366),
day_number: z.number().int().min(1).max(MAX_TRIP_DAYS),
from_assignment_id: z.number().int().positive(),
to_assignment_id: z.number().int().positive().nullable(),
fraction: z.number().min(0).max(1),
+6 -3
View File
@@ -1,3 +1,4 @@
import { MAX_TRIP_DAYS } from '../trip/trip.schema';
import { dayWindow, planDayWindow, roadtripInsertion } from './dayWindow';
import type { RoadtripStop, RoutedLeg } from './planning-types';
@@ -360,9 +361,11 @@ describe('daily travel window', () => {
});
it('bounds extreme driving and stay durations', () => {
expect(calculate([stop(1), stop(2)], [600 * 367]).issue).toBe('tooLong');
// Counted against the clock, so a year of standing still is 1440 minutes a day.
expect(calculate([stop(1, { dwellMinutes: 1440 * 400 }), stop(2)], [1]).issue).toBe('tooLong');
expect(calculate([stop(1), stop(2)], [600 * (MAX_TRIP_DAYS + 2)]).issue).toBe('tooLong');
// Counted against the clock, so standing still is 1440 minutes a day.
expect(calculate([stop(1, { dwellMinutes: 1440 * (MAX_TRIP_DAYS + 35) }), stop(2)], [1]).issue).toBe('tooLong');
// A road trip may run as long as the trip itself.
expect(calculate([stop(1), stop(2)], [600 * 400]).issue).toBeNull();
});
it('maps insertions on generated days back to real stored stops', () => {
+2 -1
View File
@@ -1,3 +1,4 @@
import { MAX_TRIP_DAYS } from '../trip/trip.schema';
import { lineMetres, projectOntoRoute, sliceAtMeters } from './corridor';
import type { RoadtripDayBoundary } from './day-boundary.schema';
import type { SpillChain } from './nightSpill';
@@ -191,7 +192,7 @@ export function planDayWindow(
if (last) putLeg(last, end, stationary(at));
if (final) return end;
number += 1;
if (number - firstDay > 365) issue = 'tooLong';
if (number - firstDay > MAX_TRIP_DAYS) issue = 'tooLong';
clock = window.start;
const start: RoadtripStop = {
...end,
@@ -1,3 +1,5 @@
import { MAX_TRIP_DAYS } from '../trip/trip.schema';
import { roadtripDayBoundarySchema } from './day-boundary.schema';
import { roadtripPlanRequestSchema, roadtripCorridorRequestSchema, roadtripGpxImportSchema } from './planning.schema';
import { roadtripPreferencesUpdateSchema } from './preferences.schema';
@@ -36,6 +38,18 @@ describe('Roadtrip MCP contracts', () => {
roadtripCorridorRequestSchema.safeParse({ tripId: 1, dayNumber: 1, category: 'charging', widthKm: 500 }).success,
).toBe(false);
});
it('lets day numbers run to the trip limit, not to a year (#2403)', () => {
const corridor = (dayNumber: number) =>
roadtripCorridorRequestSchema.safeParse({ tripId: 1, dayNumber, category: 'fuel' });
expect(corridor(400).success).toBe(true);
expect(corridor(MAX_TRIP_DAYS).success).toBe(true);
expect(corridor(MAX_TRIP_DAYS + 1).success).toBe(false);
const boundary = (day_number: number) =>
roadtripDayBoundarySchema.safeParse({ day_number, from_assignment_id: 1, to_assignment_id: 2, fraction: 0.5 });
expect(boundary(400).success).toBe(true);
expect(boundary(MAX_TRIP_DAYS).success).toBe(true);
expect(boundary(MAX_TRIP_DAYS + 1).success).toBe(false);
});
it('bounds GPX input and defaults all supported import types', () => {
expect(roadtripGpxImportSchema.parse({ tripId: 1, gpx: '<gpx/>' })).toMatchObject({
importTracks: true,
+2 -1
View File
@@ -1,4 +1,5 @@
import { idSchema } from '../common/primitives.schema';
import { MAX_TRIP_DAYS } from '../trip/trip.schema';
import { roadtripPreferencesSchema } from './preferences.schema';
import { z } from 'zod';
@@ -10,7 +11,7 @@ export const roadtripPlanRequestSchema = z.object({
});
export const roadtripCorridorRequestSchema = z.object({
tripId: idSchema,
dayNumber: z.number().int().positive().max(366),
dayNumber: z.number().int().positive().max(MAX_TRIP_DAYS),
category: z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights', 'hotel']),
widthKm: z.number().positive().max(10).default(5),
offset: z.number().int().min(0).max(1000).default(0),