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
@@ -22,7 +22,7 @@ import { spurWorthLabelling } from './accessSpur'
import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker'
import { useVehicleRange } from './useVehicleRange'
import type { RoadtripStopType } from '@trek/shared'
import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared'
import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { SpillMark } from './nightSpill'
import { dayColor } from './dayColors'
@@ -1772,7 +1772,7 @@ export default function RoadtripSidebar({
<TripSummary routes={routes} />
{routes.dayWindowIssue ? (
<p role="status" className="mx-3.5 mt-2 rounded-xl bg-warning-soft p-3 text-caption text-content">
{t(`roadtrip.window.${routes.dayWindowIssue}`)}
{t(`roadtrip.window.${routes.dayWindowIssue}`, { days: MAX_TRIP_DAYS })}
</p>
) : null}
</div>
@@ -54,7 +54,7 @@ import type { ManualStopTarget, ServiceStopMode } from '../../components/Roadtri
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
import type { StayDraft } from '../../components/Roadtrip/RoadtripStayModal'
import { inspectorStay } from '../../components/Roadtrip/stayReading'
import type { RoadtripStopType } from '@trek/shared'
import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
@@ -1429,7 +1429,7 @@ export function useTripPlanner() {
const next = dayBoundaries.boundaries.filter(b => b.day_number !== day)
if (boundary) next.push(boundary)
const issue = roadtripRoutes.validateBoundaries?.(next)
if (boundary && issue) { toast.error(t(`roadtrip.window.${issue}`)); return false }
if (boundary && issue) { toast.error(t(`roadtrip.window.${issue}`, { days: MAX_TRIP_DAYS })); return false }
try { return await dayBoundaries.save(day, boundary) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')); return false }
},
+66
View File
@@ -5146,6 +5146,72 @@ function runMigrations(db: Database.Database): void {
.get();
if (!hasColumn) db.exec('ALTER TABLE document_sync_items ADD COLUMN remote_trashed_at TEXT');
},
/*
* Trips longer than a year lost every day past the 365th: generateDays
* clipped the day rows at the old limit while the trip kept its full end
* date, so the last days had a date but nothing to plan on (#2403). The
* limit is 999 now, and this gives the affected trips their missing days.
*
* Only a range whose dated days still run unbroken from the start date is
* extended; a trip that was re-dated by hand or lost a day in the middle is
* left as it is. Dateless days that still hold content stay behind the
* dated ones, where generateDays keeps them. The two-phase renumbering is
* the same dance generateDays does around UNIQUE(trip_id, day_number).
*/
() => {
const dayAfter = (start: string, n: number) => {
const [y, m, d] = start.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d) + n * 86400000).toISOString().slice(0, 10);
};
const trips = db
.prepare(`
SELECT id, start_date, end_date,
CAST(julianday(end_date) - julianday(start_date) + 1 AS INTEGER) AS span
FROM trips
WHERE start_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'
AND end_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'
AND julianday(end_date) - julianday(start_date) + 1 BETWEEN 366 AND 999
`)
.all() as { id: number; start_date: string; end_date: string; span: number }[];
const dayRows = db.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY day_number');
const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
for (const trip of trips) {
const rows = dayRows.all(trip.id) as { id: number; date: string | null }[];
const dated = rows.filter((r) => r.date);
if (dated.length >= trip.span) continue;
if (dated.some((r, i) => r.date !== dayAfter(trip.start_date, i))) continue;
const dateless = rows.filter((r) => !r.date);
rows.forEach((r, i) => setDayNumber.run(-(i + 1), r.id));
dated.forEach((r, i) => setDayNumber.run(i + 1, r.id));
for (let i = dated.length; i < trip.span; i++) insertDay.run(trip.id, i + 1, dayAfter(trip.start_date, i));
dateless.forEach((r, i) => setDayNumber.run(trip.span + i + 1, r.id));
}
},
/*
* The road-trip day boundaries carried the old trip limit as a CHECK on
* day_number. The limit lives in the contract now (MAX_TRIP_DAYS), so the
* table keeps only the floor. SQLite cannot alter a CHECK, hence the
* rebuild; nothing references the table, so the rows are simply copied.
*/
() => {
db.exec(`
CREATE TABLE roadtrip_day_boundaries_new (
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL CHECK (day_number >= 1),
from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE,
fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1),
PRIMARY KEY (trip_id, day_number)
);
INSERT INTO roadtrip_day_boundaries_new
SELECT trip_id, day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries;
DROP TABLE roadtrip_day_boundaries;
ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries;
`);
},
];
if (currentVersion < migrations.length) {
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { roadtripDayBoundarySchema, type RoadtripDayBoundary } from '@trek/shared';
import { MAX_TRIP_DAYS, roadtripDayBoundarySchema, type RoadtripDayBoundary } from '@trek/shared';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, ok, type McpContext } from '../../nest-mcp';
import { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { DatabaseService } from '../database/database.service';
@@ -37,7 +37,7 @@ export class DayBoundariesMcp {
@Tool({
name: 'set_day_boundary',
description: 'Override a road trip day ending at a visit or a fraction along the driving leg between consecutive visits. Daily travel times must be enabled in the planner. Fixed visit times stay protected and conflicts are shown. Pass null to restore the automatic day ending.',
inputSchema: { tripId: z.number().int().positive(), dayNumber: z.number().int().min(1).max(366), boundary: roadtripDayBoundarySchema.nullable() },
inputSchema: { tripId: z.number().int().positive(), dayNumber: z.number().int().min(1).max(MAX_TRIP_DAYS), boundary: roadtripDayBoundarySchema.nullable() },
annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, when,
})
async save({ tripId, dayNumber, boundary }: { tripId: number; dayNumber: number; boundary: RoadtripDayBoundary | null }, ctx: McpContext) {
@@ -115,6 +115,8 @@ const ALLOWED_DESTRUCTIVE: Record<string, string> = {
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
'DROP TABLE journeys':
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
'DROP TABLE roadtrip_day_boundaries':
'Rebuild to lift the day_number CHECK that mirrored the old 365-day trip limit (#2403). Rows copied first.',
// ── template/cache scaffolding drops (no user content lost) ──────────────
'DROP TABLE packing_template_items':
@@ -0,0 +1,170 @@
/**
* Boot migration: days a trip lost past the old 365-day limit (#2403).
*
* generateDays used to clip the day rows while the trip kept its full end
* date. The migration appends the missing dated days to a trip whose dated
* rows are still the unbroken run from its start date, keeps content-bearing
* dateless days behind them, and leaves every other trip alone.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import Database from 'better-sqlite3';
import { describe, it, expect } from 'vitest';
/** Positions of the two #2403 steps in the migration array (the number the boot log prints). */
const BACKFILL_VERSION = 240;
const REBUILD_VERSION = 241;
function dayAfter(start: string, n: number) {
return new Date(Date.parse(start + 'T00:00:00Z') + n * 86400000).toISOString().slice(0, 10);
}
function freshDb(): Database.Database {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA busy_timeout = 5000');
db.exec('PRAGMA foreign_keys = ON');
createTables(db);
runMigrations(db);
db.prepare("INSERT INTO users (id, username, email, password_hash) VALUES (1, 'u', 'u@example.test', 'x')").run();
return db;
}
/** A trip the old code produced: `dated` day rows from start_date, whatever the end date says. */
function seedClippedTrip(db: Database.Database, id: number, start: string, end: string, dated: number) {
db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (?, 1, ?, ?, ?)').run(
id,
`T${id}`,
start,
end,
);
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
for (let i = 0; i < dated; i++) insert.run(id, i + 1, dayAfter(start, i));
}
function days(db: Database.Database, tripId: number) {
return db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as {
id: number;
day_number: number;
date: string | null;
}[];
}
/** Rewind schema_version so the given step (and everything after it) replays. */
function replayFrom(db: Database.Database, version: number) {
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
runMigrations(db);
}
describe('trip days backfill migration (#2403)', () => {
it('MIGRATE-DAYS-001: appends the days a clipped trip is missing, in order and dated', () => {
const db = freshDb();
try {
seedClippedTrip(db, 1, '2025-01-26', '2026-01-28', 365);
const before = days(db, 1);
replayFrom(db, BACKFILL_VERSION);
const after = days(db, 1);
expect(after).toHaveLength(368);
// The rows that were there keep their ids and numbers.
expect(after.slice(0, 365).map((d) => d.id)).toEqual(before.map((d) => d.id));
expect(after.map((d) => d.day_number)).toEqual(after.map((_, i) => i + 1));
expect(after.slice(365).map((d) => d.date)).toEqual(['2026-01-26', '2026-01-27', '2026-01-28']);
} finally {
db.close();
}
});
it('MIGRATE-DAYS-002: content-bearing dateless days stay behind the dated ones', () => {
const db = freshDb();
try {
seedClippedTrip(db, 1, '2025-01-26', '2026-01-28', 365);
const spare = Number(
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (1, 366, NULL)').run().lastInsertRowid,
);
db.prepare("INSERT INTO day_notes (day_id, trip_id, text) VALUES (?, 1, 'keep me')").run(spare);
replayFrom(db, BACKFILL_VERSION);
const after = days(db, 1);
expect(after).toHaveLength(369);
expect(after[367]).toMatchObject({ day_number: 368, date: '2026-01-28' });
expect(after[368]).toMatchObject({ id: spare, day_number: 369, date: null });
} finally {
db.close();
}
});
it('MIGRATE-DAYS-003: leaves trips alone that were never clipped, were re-dated by hand, or exceed the new limit', () => {
const db = freshDb();
try {
// Complete already.
seedClippedTrip(db, 1, '2026-07-01', '2026-07-07', 7);
// Longer than a year but its days no longer start on start_date.
seedClippedTrip(db, 2, '2025-01-26', '2026-01-28', 365);
db.prepare("UPDATE days SET date = '2024-12-31' WHERE trip_id = 2 AND day_number = 1").run();
// Past the new limit: still refused, so still not extended.
seedClippedTrip(db, 3, '2020-01-01', '2030-01-01', 365);
// Dateless trip.
db.prepare("INSERT INTO trips (id, user_id, title) VALUES (4, 1, 'no dates')").run();
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (4, 1, NULL)').run();
const snapshot = [1, 2, 3, 4].map((id) => days(db, id));
replayFrom(db, BACKFILL_VERSION);
expect([1, 2, 3, 4].map((id) => days(db, id))).toEqual(snapshot);
} finally {
db.close();
}
});
it('MIGRATE-DAYS-004: running it twice changes nothing more', () => {
const db = freshDb();
try {
seedClippedTrip(db, 1, '2025-01-26', '2026-01-28', 365);
replayFrom(db, BACKFILL_VERSION);
const once = days(db, 1);
replayFrom(db, BACKFILL_VERSION);
expect(days(db, 1)).toEqual(once);
} finally {
db.close();
}
});
});
describe('road-trip day boundary rebuild (#2403)', () => {
it('MIGRATE-DAYS-005: keeps existing boundaries and accepts a day past the old 366 ceiling', () => {
const db = freshDb();
try {
// The table as every installation before this migration has it.
db.exec(`
DROP TABLE roadtrip_day_boundaries;
CREATE TABLE roadtrip_day_boundaries (
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL CHECK (day_number BETWEEN 1 AND 366),
from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE,
fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1),
PRIMARY KEY (trip_id, day_number)
)`);
seedClippedTrip(db, 1, '2025-01-01', '2027-06-30', 911);
db.prepare('INSERT INTO places (trip_id, name) VALUES (1, ?), (1, ?)').run('A', 'B');
db.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (1, 1), (2, 2)').run();
const boundary = db.prepare(
'INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (1, ?, 1, 2, 0.5)',
);
boundary.run(2);
expect(() => boundary.run(400)).toThrow(/CHECK/);
replayFrom(db, REBUILD_VERSION);
boundary.run(400);
const rows = db.prepare('SELECT day_number, fraction FROM roadtrip_day_boundaries ORDER BY day_number').all();
expect(rows).toEqual([
{ day_number: 2, fraction: 0.5 },
{ day_number: 400, fraction: 0.5 },
]);
expect(() => boundary.run(0)).toThrow(/CHECK/);
} finally {
db.close();
}
});
});
+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),