diff --git a/client/src/components/Trips/TripFormModal.test.tsx b/client/src/components/Trips/TripFormModal.test.tsx index a75f301fc..cbe045694 100644 --- a/client/src/components/Trips/TripFormModal.test.tsx +++ b/client/src/components/Trips/TripFormModal.test.tsx @@ -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(); - 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(); 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(); - 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(); + 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(); + + 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(); + 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' }) + )); + }); }); diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index 5b954eaa1..526e0f1e4 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -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 - { 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} />

{t('dashboard.dayCountHint')}

diff --git a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx index e046b0a54..a2bc80c31 100644 --- a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx +++ b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx @@ -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 { diff --git a/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx b/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx index 441519042..9520ce9c8 100644 --- a/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx +++ b/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx @@ -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( {}} 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(); diff --git a/server/src/mcp/tools/_shared.ts b/server/src/mcp/tools/_shared.ts index d0a2b4b9a..9b6a7922f 100644 --- a/server/src/mcp/tools/_shared.ts +++ b/server/src/mcp/tools/_shared.ts @@ -17,8 +17,6 @@ export { TOOL_ANNOTATIONS_WRITE, } from '../../nest-mcp'; -export const MAX_MCP_TRIP_DAYS = 90; - export function noAccess() { return errorResult('Trip not found or access denied.'); } diff --git a/server/src/nest/mcp-shared/mcp-tool-guards.service.ts b/server/src/nest/mcp-shared/mcp-tool-guards.service.ts index 891056ce2..d8a9bfba4 100644 --- a/server/src/nest/mcp-shared/mcp-tool-guards.service.ts +++ b/server/src/nest/mcp-shared/mcp-tool-guards.service.ts @@ -8,7 +8,7 @@ import { RealtimeService } from '../realtime/realtime.service'; * src/mcp/tools/_shared.ts, reaching the db Proxy, permissions.bridge and the * src/websocket stub as module globals. The @McpController domain classes are * ordinary Nest providers, so they inject this instead. The pure result - * helpers (noAccess/permissionDenied/adminRequired/MAX_MCP_TRIP_DAYS and the + * helpers (noAccess/permissionDenied/adminRequired and the * src/nest-mcp re-exports) stay in _shared.ts — they carry no dependencies. */ @Injectable() diff --git a/server/src/nest/mcp-transport/mcp-transport.constants.ts b/server/src/nest/mcp-transport/mcp-transport.constants.ts index a0396c0cd..dc687fcd7 100644 --- a/server/src/nest/mcp-transport/mcp-transport.constants.ts +++ b/server/src/nest/mcp-transport/mcp-transport.constants.ts @@ -44,7 +44,7 @@ You are connected to TREK, a travel planning application. Below is a compact ref - The authenticated user can only access trips they own or are a member of. Never guess at trip IDs. - Only the trip owner can delete the trip, add members, or remove members. - Deleting a place removes all of its day assignments as well — warn the user before doing this. -- Trips created via MCP are capped at 90 days. +- A trip can span at most 999 days, by date range or by day_count. ## Dates and times diff --git a/server/src/nest/reservation-import/reservation-import.mcp.ts b/server/src/nest/reservation-import/reservation-import.mcp.ts index 3b67928e8..2f412fd69 100644 --- a/server/src/nest/reservation-import/reservation-import.mcp.ts +++ b/server/src/nest/reservation-import/reservation-import.mcp.ts @@ -20,8 +20,8 @@ const airtrailAddonOn = addonGate(ADDON_IDS.AIRTRAIL); /** * The picker sends whatever a human ticked; a model can name a hundred ids off * one hallucinated list, and each one becomes a reservation and a broadcast. - * The same reasoning as MAX_MCP_TRIP_DAYS: the tool surface caps a bulk write - * the REST route leaves open, and says how to continue. + * The tool surface caps a bulk write the REST route leaves open, and says how + * to continue. */ const MAX_MCP_AIRTRAIL_FLIGHTS = 50; diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index eb940b8bb..91c4d36b0 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -23,7 +23,7 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service'; import type { Request, Response } from 'express'; import type { Options } from 'multer'; import path from 'path'; -import type { ActiveTripResponse } from '@trek/shared'; +import { MAX_TRIP_DAYS, type ActiveTripResponse } from '@trek/shared'; import { StorageService } from '../storage/storage.service'; import type { User } from '../../types'; import { TripsService } from './trips.service'; @@ -116,11 +116,16 @@ export class TripsController { if (start_date && end_date && new Date(end_date) < new Date(start_date)) { throw new HttpException({ error: 'End date must be after start date' }, 400); } - const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), 365) : undefined; - const { trip, tripId, reminderDays } = this.trips.create(user.id, { title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount }); - this.audit.writeAudit({ userId: user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } }); - if (reminderDays > 0) logInfo(`${user.email} set ${reminderDays}-day reminder for trip "${title}"`); - return { trip }; + const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), MAX_TRIP_DAYS) : undefined; + try { + const { trip, tripId, reminderDays } = this.trips.create(user.id, { title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount }); + this.audit.writeAudit({ userId: user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } }); + if (reminderDays > 0) logInfo(`${user.email} set ${reminderDays}-day reminder for trip "${title}"`); + return { trip }; + } catch (e: unknown) { + if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400); + throw e; + } } @Get(':id') diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index 483abc018..efb20d16f 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -12,9 +12,10 @@ import { CalendarService } from '../calendar/calendar.service'; import { TripMembersService } from '../trip-members/trip-members.service'; import { TripReadModelService } from '../trip-read-model/trip-read-model.service'; import { ADDON_IDS } from '../../addons'; -import { MAX_MCP_TRIP_DAYS, noAccess, permissionDenied } from '../../mcp/tools/_shared'; +import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { canRead, canReadTrips, canDeleteTrips } from '../../mcp/scopes'; -import { TripsService, MAX_TRIP_DAYS, NotFoundError, ValidationError } from './trips.service'; +import { MAX_TRIP_DAYS } from '@trek/shared'; +import { TripsService, NotFoundError, ValidationError } from './trips.service'; import { TodoService } from '../todo/todo.service'; import { CollabService } from '../collab/collab.service'; import { AddonsService } from '../addons/addons.service'; @@ -81,7 +82,7 @@ export class TripsMcp { @Tool({ name: 'create_trip', - description: 'Create a new trip. Returns the created trip with its generated days.', + description: 'Create a new trip. Returns the created trip; its day_count is the number of days generated, one per day of the date range.', inputSchema: { title: z.string().min(1).max(200).describe('Trip title'), description: z.string().max(2000).optional().describe('Trip description'), @@ -117,8 +118,13 @@ export class TripsMcp { if (start_date && end_date && new Date(end_date) < new Date(start_date)) { return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true }; } - const { trip } = this.trips.create(ctx.userId, { title, description, start_date, end_date, currency, day_count, reminder_days }, MAX_MCP_TRIP_DAYS); - return ok({ trip }); + try { + const { trip } = this.trips.create(ctx.userId, { title, description, start_date, end_date, currency, day_count, reminder_days }); + return ok({ trip }); + } catch (err) { + if (err instanceof ValidationError) return errorResult(err.message); + throw err; + } } @Tool({ @@ -177,9 +183,14 @@ export class TripsMcp { : { start_date, end_date }; // update() re-anchors the budget before the trip row moves off the old // currency (#1543) and then runs the legacy updateTrip core. - const { updatedTrip } = await this.trips.update(tripId, ctx.userId, { title, description, ...dates, currency, is_archived, cover_image, day_count, reminder_days, date_shift_mode }, 'user'); - this.guards.safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip }); - return ok({ trip: updatedTrip }); + try { + const { updatedTrip } = await this.trips.update(tripId, ctx.userId, { title, description, ...dates, currency, is_archived, cover_image, day_count, reminder_days, date_shift_mode }, 'user'); + this.guards.safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip }); + return ok({ trip: updatedTrip }); + } catch (err) { + if (err instanceof ValidationError) return errorResult(err.message); + throw err; + } } @Tool({ diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index ae2ed81c0..f500def9d 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import path from 'path'; import { DatabaseService } from '../database/database.service'; -import type { ActiveTrip, TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; +import { MAX_TRIP_DAYS, tripSpanDays, type ActiveTrip, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import type { Trip, User } from '../../types'; @@ -14,7 +14,19 @@ import { StorageService } from '../storage/storage.service'; import { NotFoundError, ValidationError } from '../common/domain-errors'; export const MS_PER_DAY = 86400000; -export const MAX_TRIP_DAYS = 365; + +/** + * The date range is refused, not cut short: generateDays used to clip the day + * rows at the limit while the trip kept its full end date, so everything past + * the cut-off had dates but no day to go on (#2403). An inverted range is + * refused here as well, because a start date moved past the stored end date + * arrives on its own and would otherwise empty the trip. + */ +function assertTripSpan(startDate: string, endDate: string) { + const span = tripSpanDays(startDate, endDate); + if (span < 1) throw new ValidationError('End date must be after start date'); + if (span > MAX_TRIP_DAYS) throw new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`); +} /** * Strips `feed_token` from a trip row on its way out. @@ -171,7 +183,7 @@ export class TripsService { // ── Day generation ──────────────────────────────────────────────────────── - generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, maxDays?: number, dayCount?: number) { + generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, dayCount?: number) { const existing = this.db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[]; const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); @@ -215,10 +227,8 @@ export class TripsService { } const [sy, sm, sd] = startDate.split('-').map(Number); - const [ey, em, ed] = endDate.split('-').map(Number); const startMs = Date.UTC(sy, sm - 1, sd); - const endMs = Date.UTC(ey, em - 1, ed); - const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, maxDays ?? MAX_TRIP_DAYS); + const numDays = tripSpanDays(startDate, endDate); const targetDates: string[] = []; for (let i = 0; i < numDays; i++) { @@ -309,7 +319,8 @@ export class TripsService { `).all({ userId, archived }); } - create(userId: number, data: CreateTripData, maxDays?: number) { + create(userId: number, data: CreateTripData) { + if (data.start_date && data.end_date) assertTripSpan(data.start_date, data.end_date); const rd = data.reminder_days !== undefined ? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3) : 3; @@ -320,7 +331,7 @@ export class TripsService { `).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd); const tripId = result.lastInsertRowid; - this.generateDays(tripId, data.start_date || null, data.end_date || null, maxDays, data.day_count); + this.generateDays(tripId, data.start_date || null, data.end_date || null, data.day_count); const trip = this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId }); return { trip, tripId: Number(tripId), reminderDays: rd }; @@ -384,15 +395,11 @@ export class TripsService { const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip & { reminder_days?: number } | undefined; if (!trip) throw new NotFoundError('Trip not found'); - const { title, description, start_date, end_date, currency, is_archived, cover_image, reminder_days } = data; - - if (start_date && end_date && new Date(end_date) < new Date(start_date)) - throw new ValidationError('End date must be after start date'); + const { title, description, currency, is_archived, cover_image, reminder_days } = data; + const { newStart, newEnd, dayCount, regenerate } = this.resolveRange(trip, data); const newTitle = title || trip.title; const newDesc = description !== undefined ? description : trip.description; - const newStart = start_date !== undefined ? start_date : trip.start_date; - const newEnd = end_date !== undefined ? end_date : trip.end_date; const newCurrency = currency || trip.currency; const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived; const newCover = cover_image !== undefined ? cover_image : trip.cover_image; @@ -410,8 +417,7 @@ export class TripsService { if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date) this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); - const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined; - if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount) { + if (regenerate) { this.db.transaction(() => { // Accommodations have no absolute date columns, so their pre-change dates must be // snapshotted before generateDays re-dates the day rows in place. @@ -419,7 +425,7 @@ export class TripsService { (this.db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[]) .map(d => [d.id, d.date]), ); - this.generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount); + this.generateDays(tripId, newStart || null, newEnd || null, dayCount); if (data.date_shift_mode === 'shift_all') { // Explicit "shift everything": bookings stay glued to their (re-dated) day rows, // so re-stamp reservation_time to follow — same rules as reorderDays/insertDay. @@ -456,7 +462,31 @@ export class TripsService { return { updatedTrip, changes, isAdminEdit, ownerEmail, newTitle, newReminder, oldReminder }; } + /** + * The dates the update leaves the trip with, checked before anything is + * written. The day grid is rebuilt whenever a date moves or a day_count + * arrives, and a dated rebuild runs over the whole range, so the range is + * held to the limit exactly then. A trip stored with a longer range can + * still be renamed. + */ + private resolveRange(trip: Trip, data: UpdateTripData) { + const { start_date, end_date } = data; + if (start_date && end_date && new Date(end_date) < new Date(start_date)) + throw new ValidationError('End date must be after start date'); + const newStart = start_date !== undefined ? start_date : trip.start_date; + const newEnd = end_date !== undefined ? end_date : trip.end_date; + const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined; + const regenerate = newStart !== trip.start_date || newEnd !== trip.end_date || dayCount !== undefined; + if (regenerate && newStart && newEnd) assertTripSpan(newStart, newEnd); + return { newStart, newEnd, dayCount, regenerate }; + } + async update(tripId: string | number, userId: number, body: UpdateTripData, role: string) { + // A refused range must not leave the budget rebased onto a currency the trip + // never took, so the dates are checked before the first write. + const trip = this.getRaw(tripId); + if (!trip) throw new NotFoundError('Trip not found'); + this.resolveRange(trip, body); // Re-anchor the budget while the outgoing currency is still on the trip row, // otherwise the frozen FX rates and the currency-less expenses that inherit the // trip's base are left pointing at a currency that no longer exists (#1543). diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index f70c761dd..21ba5a733 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -12,6 +12,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; +import { MAX_TRIP_DAYS } from '@trek/shared'; const { db } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -197,6 +198,33 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { expect(forbidden.status).toBe(403); }); + it('201 create keeps every day of a trip longer than a year (#2403)', async () => { + // 2025-01-26 .. 2026-01-28 is 368 days; the day list used to stop at 365. + const res = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)) + .send({ title: 'Gap year', start_date: '2025-01-26', end_date: '2026-01-28' }); + expect(res.status).toBe(201); + expect(res.body.trip).toMatchObject({ start_date: '2025-01-26', end_date: '2026-01-28', day_count: 368 }); + const last = db.prepare('SELECT day_number, date FROM days WHERE trip_id = ? ORDER BY day_number DESC LIMIT 1') + .get(res.body.trip.id) as { day_number: number; date: string }; + expect(last).toEqual({ day_number: 368, date: '2026-01-28' }); + }); + + it('400 on a date range past MAX_TRIP_DAYS, for create and update alike', async () => { + const tooLong = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)) + .send({ title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' }); + expect(tooLong.status).toBe(400); + expect(tooLong.body).toEqual({ error: `A trip can span at most ${MAX_TRIP_DAYS} days` }); + expect(db.prepare('SELECT COUNT(*) AS n FROM trips').get()).toEqual({ n: 0 }); + + const week = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)) + .send({ title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' }); + const stretched = await request(server).put(`/api/trips/${week.body.trip.id}`).set('Cookie', sessionCookie(1)) + .send({ end_date: '2036-07-01' }); + expect(stretched.status).toBe(400); + expect(stretched.body).toEqual({ error: `A trip can span at most ${MAX_TRIP_DAYS} days` }); + expect(db.prepare('SELECT end_date FROM trips WHERE id = ?').get(week.body.trip.id)).toEqual({ end_date: '2026-07-07' }); + }); + it('404 on a missing trip', async () => { const res = await request(server).get('/api/trips/77').set('Cookie', sessionCookie(1)); expect(res.status).toBe(404); diff --git a/server/tests/unit/mcp/tools-trips.test.ts b/server/tests/unit/mcp/tools-trips.test.ts index a87550953..af761f32c 100644 --- a/server/tests/unit/mcp/tools-trips.test.ts +++ b/server/tests/unit/mcp/tools-trips.test.ts @@ -43,6 +43,7 @@ import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createDayAssignment, createDayAccommodation } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; +import { MAX_TRIP_DAYS } from '@trek/shared'; beforeAll(() => { createTables(testDb); @@ -99,16 +100,29 @@ describe('Tool: create_trip', () => { }); }); - it('caps days at 90 for very long trips', async () => { + it('generates every day of a trip longer than a year (#2403)', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'create_trip', - arguments: { title: 'Long Trip', start_date: '2026-01-01', end_date: '2027-12-31' }, + arguments: { title: 'Long Trip', start_date: '2025-01-26', end_date: '2026-01-28' }, }); const data = parseToolResult(result) as any; const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number }; - expect(days.c).toBe(90); + expect(days.c).toBe(368); + }); + }); + + it('refuses a date range longer than MAX_TRIP_DAYS instead of cutting the days short', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_trip', + arguments: { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain(`at most ${MAX_TRIP_DAYS} days`); + expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 }); }); }); @@ -153,11 +167,11 @@ describe('Tool: create_trip', () => { }); }); - it('refuses a day_count outside 1..365', async () => { + it('refuses a day_count outside 1..MAX_TRIP_DAYS', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Zero', day_count: 0 } })).isError).toBe(true); - expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Huge', day_count: 400 } })).isError).toBe(true); + expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Huge', day_count: MAX_TRIP_DAYS + 1 } })).isError).toBe(true); expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 }); }); }); @@ -378,16 +392,28 @@ describe('Tool: update_trip', () => { }); }); - it('refuses a day_count outside 1..365', async () => { + it('refuses a day_count outside 1..MAX_TRIP_DAYS', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Untouched' }); await withHarness(user.id, async (h) => { expect((await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: 0 } })).isError).toBe(true); - expect((await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: 400 } })).isError).toBe(true); + expect((await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: MAX_TRIP_DAYS + 1 } })).isError).toBe(true); expect(testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 }); }); }); + it('refuses a date range longer than MAX_TRIP_DAYS and leaves the trip untouched', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Untouched', start_date: '2026-07-01', end_date: '2026-07-07' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, end_date: '2036-07-01' } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain(`at most ${MAX_TRIP_DAYS} days`); + const row = testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id) as { end_date: string }; + expect(row.end_date).toBe('2026-07-07'); + }); + }); + it('changes reminder_days and can turn the reminder off', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index 1857d43f7..b675a1bb2 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -23,7 +23,7 @@ import type { CalendarService } from '../../../src/nest/calendar/calendar.servic import type { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service'; import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service'; import type { User } from '../../../src/types'; -import { activeTripResponseSchema, tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared'; +import { MAX_TRIP_DAYS, activeTripResponseSchema, tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared'; const user = { id: 1, role: 'user', email: 'u@example.test' } as User; const req = { headers: {} } as Request; @@ -180,6 +180,24 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: 7 })); }); + it('clamps day_count to MAX_TRIP_DAYS', () => { + const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); + tc(svc({ create } as Partial)).create(user, { title: 'T', day_count: MAX_TRIP_DAYS + 1 }, req); + expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: MAX_TRIP_DAYS })); + }); + + it('maps a ValidationError from create to 400 (range past MAX_TRIP_DAYS)', () => { + const create = vi.fn().mockImplementation(() => { throw new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`); }); + expect(thrown(() => tc(svc({ create } as Partial)).create(user, { title: 'T', start_date: '2026-01-01', end_date: '2036-01-01' }, req))).toEqual({ + status: 400, body: { error: `A trip can span at most ${MAX_TRIP_DAYS} days` }, + }); + }); + + it('re-throws an unknown error from create', () => { + const create = vi.fn().mockImplementation(() => { throw new Error('boom'); }); + expect(() => tc(svc({ create } as Partial)).create(user, { title: 'T' }, req)).toThrow('boom'); + }); + it('logs the reminder when reminderDays is set', () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 3 }); expect(tc(svc({ create } as Partial)).create(user, { title: 'T' }, req)).toEqual({ trip: { id: 9 } }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 77c56fc0a..b478a4a83 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -54,6 +54,7 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote, addTripMember } from '../../helpers/factories'; +import { MAX_TRIP_DAYS } from '@trek/shared'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { DaysService } from '../../../src/nest/days/days.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; @@ -169,6 +170,10 @@ function getNotes(dayId: number) { return testDb.prepare('SELECT * FROM day_notes WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[]; } +function addDaysIso(date: string, n: number) { + return new Date(Date.parse(date + 'T00:00:00Z') + n * 86400000).toISOString().slice(0, 10); +} + // ── Tests ───────────────────────────────────────────────────────────────────── describe('generateDays', () => { @@ -260,6 +265,24 @@ describe('generateDays', () => { expect(getAssignments(daysAfter[4].id)).toHaveLength(0); }); + it('TRIP-SVC-062: a range longer than a year gets every one of its days (#2403)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-01-26', end_date: '2025-01-28' }); + // The reporter's range: 368 days, and the days used to stop at 365. + svc.generateDays(trip.id, '2025-01-26', '2026-01-28'); + const days = getDays(trip.id); + expect(days).toHaveLength(368); + expect(days[364].date).toBe('2026-01-25'); + expect(days[367]).toMatchObject({ day_number: 368, date: '2026-01-28' }); + }); + + it('TRIP-SVC-063: a dateless day_count is clamped to MAX_TRIP_DAYS', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + svc.generateDays(trip.id, null, null, MAX_TRIP_DAYS + 50); + expect(getDays(trip.id)).toHaveLength(MAX_TRIP_DAYS); + }); + it('TRIP-SVC-013: clearing dates converts all days to dateless without destroying assignments', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-09-01', end_date: '2025-09-04' }); @@ -722,6 +745,17 @@ describe('folded trip CRUD', () => { expect(getDays(tripId)).toHaveLength(3); }); + it('TRIP-SVC-064: create refuses a range past MAX_TRIP_DAYS and writes nothing', () => { + const { user } = createUser(testDb); + const before = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n; + expect(() => svc.create(user.id, { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' })) + .toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); + expect((testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n).toBe(before); + // The longest allowed range goes through in full. + const { tripId } = svc.create(user.id, { title: 'Longest', start_date: '2026-01-01', end_date: addDaysIso('2026-01-01', MAX_TRIP_DAYS - 1) }); + expect(getDays(tripId)).toHaveLength(MAX_TRIP_DAYS); + }); + it('TRIP-SVC-045: remove deletes the trip, cleans skeleton journey entries and detaches filled ones', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -930,13 +964,15 @@ describe('folded trip CRUD', () => { describe('TripsService wrapper helpers', () => { it('re-anchors the budget before the trip row leaves its old currency (#1543)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); const order: string[] = []; const rebaseSpy = vi.spyOn(budgetSvc, 'rebaseTripCurrency').mockImplementation(async () => { order.push('rebase'); }); const updateSpy = vi.spyOn(svc, 'updateTrip').mockImplementation(() => { order.push('update'); return {} as never; }); try { - await svc.update('9', 1, { currency: 'RUB' } as never, 'user'); + await svc.update(trip.id, user.id, { currency: 'RUB' } as never, 'user'); // The rebase reads the outgoing currency off the trip row, so it has to run first. - expect(rebaseSpy).toHaveBeenCalledWith('9', 'RUB'); + expect(rebaseSpy).toHaveBeenCalledWith(trip.id, 'RUB'); expect(order).toEqual(['rebase', 'update']); } finally { rebaseSpy.mockRestore(); @@ -944,6 +980,22 @@ describe('TripsService wrapper helpers', () => { } }); + it('TRIP-SVC-068: update refuses a bad range before the budget is rebased (#2403)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-07' }); + const rebaseSpy = vi.spyOn(budgetSvc, 'rebaseTripCurrency').mockResolvedValue(); + try { + await expect(svc.update(trip.id, user.id, { currency: 'USD', end_date: '2036-07-01' }, 'user')) + .rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); + await expect(svc.update(trip.id, user.id, { currency: 'USD', start_date: '2026-07-10' }, 'user')) + .rejects.toThrow('End date must be after start date'); + expect(rebaseSpy).not.toHaveBeenCalled(); + await expect(svc.update(99999, user.id, { currency: 'USD' }, 'user')).rejects.toThrow('Trip not found'); + } finally { + rebaseSpy.mockRestore(); + } + }); + it('canAccessTrip delegates to the db helper; can() delegates to checkPermission; broadcast forwards', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -1006,6 +1058,40 @@ describe('folded quirk branches', () => { expect(() => svc.updateTrip(trip.id, owner.id, { start_date: '2025-06-10', end_date: '2025-06-01' }, 'user')).toThrow('End date must be after start date'); }); + it('TRIP-SVC-065: updateTrip refuses a range past MAX_TRIP_DAYS before touching the row', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' }); + expect(() => svc.updateTrip(trip.id, user.id, { title: 'Decade', end_date: '2036-07-01' }, 'user')) + .toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); + expect(testDb.prepare('SELECT title, end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ title: 'Week', end_date: '2026-07-07' }); + expect(getDays(trip.id)).toHaveLength(7); + // Moving only the start keeps the stored end and is measured against it. + expect(() => svc.updateTrip(trip.id, user.id, { start_date: '2020-01-01' }, 'user')) + .toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); + }); + + it('TRIP-SVC-066: a trip whose stored range already exceeds the limit can still be renamed', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Legacy' }); + testDb.prepare("UPDATE trips SET start_date = '2020-01-01', end_date = '2030-01-01' WHERE id = ?").run(trip.id); + const result = svc.updateTrip(trip.id, user.id, { title: 'Renamed' }, 'user'); + expect(result.newTitle).toBe('Renamed'); + expect(result.changes).toEqual({ title: 'Renamed' }); + // A day_count would rebuild the grid over the whole stored range, so it is held to the limit too. + expect(() => svc.updateTrip(trip.id, user.id, { day_count: 5 }, 'user')) + .toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); + expect(getDays(trip.id)).toHaveLength(0); + }); + + it('TRIP-SVC-067: a start date moved past the stored end is refused instead of emptying the trip', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' }); + expect(() => svc.updateTrip(trip.id, user.id, { start_date: '2026-07-10' }, 'user')) + .toThrow('End date must be after start date'); + expect(testDb.prepare('SELECT start_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ start_date: '2026-07-01' }); + expect(getDays(trip.id)).toHaveLength(7); + }); + it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', () => { const { user: owner } = createUser(testDb); const { user: invitee } = createUser(testDb); diff --git a/server/tests/unit/trips/trips.rpc.test.ts b/server/tests/unit/trips/trips.rpc.test.ts index f5fc0c655..b27e01ccf 100644 --- a/server/tests/unit/trips/trips.rpc.test.ts +++ b/server/tests/unit/trips/trips.rpc.test.ts @@ -15,6 +15,7 @@ import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.servi import { TripsRpc } from '../../../src/nest/trips/trips.rpc'; import { TripsModule } from '../../../src/nest/trips/trips.module'; import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service'; +import { MAX_TRIP_DAYS } from '@trek/shared'; import type { TripsService } from '../../../src/nest/trips/trips.service'; import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; import type { DaysService } from '../../../src/nest/days/days.service'; @@ -33,14 +34,17 @@ const req = (method: string, params: Record = {}): RpcRequest = const ALL_TRIP_GRANTS = ['db:read:trips', 'db:write:trips', 'db:create:trips', 'db:write:members']; /** Trip 1 belongs to user 42. `allow` decides each individual permission check. */ -export function build(opts: { allow?: (action: string) => boolean; updateThrows?: Error } = {}) { +export function build(opts: { allow?: (action: string) => boolean; updateThrows?: Error; createThrows?: Error } = {}) { const trips = { list: vi.fn(() => [{ id: 1, title: 'Japan' }]), updateTrip: vi.fn(() => { if (opts.updateThrows) throw opts.updateThrows; return { updatedTrip: { id: 1, title: 'Japan' } }; }), - create: vi.fn((userId: number) => ({ trip: { id: 99, user_id: userId } })), + create: vi.fn((userId: number) => { + if (opts.createThrows) throw opts.createThrows; + return { trip: { id: 99, user_id: userId } }; + }), removeMember: vi.fn(), } as unknown as TripsService & Record>; const reservations = { list: vi.fn(() => [{ id: 5 }]) } as unknown as ReservationsService & Record>; @@ -169,6 +173,12 @@ describe('TripsRpc writes', () => { expect(res.error.message).toBe('no permission to create trips'); expect(f.trips.create).not.toHaveBeenCalled(); }); + + it('TRIPS-RPC-017 a range the service refuses comes back as BAD_PARAMS (#2403)', async () => { + const f = build({ createThrows: new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`) }); + const res = (await f.host().dispatch(req('trips.create', { input: { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' } }), 42)) as RpcError; + expect(res.error).toMatchObject({ code: 'BAD_PARAMS', message: `A trip can span at most ${MAX_TRIP_DAYS} days` }); + }); }); describe('TripsRpc membership', () => { diff --git a/shared/src/i18n/ar/dashboard.ts b/shared/src/i18n/ar/dashboard.ts index 1d6176895..62f4dec4a 100644 --- a/shared/src/i18n/ar/dashboard.ts +++ b/shared/src/i18n/ar/dashboard.ts @@ -87,6 +87,7 @@ const dashboard: TranslationStrings = { 'dashboard.useUnsplashPhoto': 'استخدم صورة من Unsplash بواسطة {photographer}', 'dashboard.titleRequired': 'العنوان مطلوب', 'dashboard.endDateError': 'يجب أن يكون تاريخ النهاية بعد البداية', + 'dashboard.tripTooLong': 'لا يمكن أن تتجاوز مدة الرحلة {days} يومًا', 'dashboard.dateShiftTitle': 'تاريخ بداية جديد', 'dashboard.dateShiftIntro': 'لقد غيّرت موعد بداية هذه الرحلة. كيف تريد أن تتبع خططك التواريخ الجديدة؟', 'dashboard.dateShiftKeepBookings': 'إبقاء الحجوزات في تواريخها', diff --git a/shared/src/i18n/br/dashboard.ts b/shared/src/i18n/br/dashboard.ts index 38d5fa711..dea693c89 100644 --- a/shared/src/i18n/br/dashboard.ts +++ b/shared/src/i18n/br/dashboard.ts @@ -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', diff --git a/shared/src/i18n/ca/dashboard.ts b/shared/src/i18n/ca/dashboard.ts index 2ecfbfd9a..e0ce84374 100644 --- a/shared/src/i18n/ca/dashboard.ts +++ b/shared/src/i18n/ca/dashboard.ts @@ -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,', diff --git a/shared/src/i18n/cs/dashboard.ts b/shared/src/i18n/cs/dashboard.ts index 03f5e4349..2a7f51de1 100644 --- a/shared/src/i18n/cs/dashboard.ts +++ b/shared/src/i18n/cs/dashboard.ts @@ -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', diff --git a/shared/src/i18n/de/dashboard.ts b/shared/src/i18n/de/dashboard.ts index ebe6e0535..8d94408f2 100644 --- a/shared/src/i18n/de/dashboard.ts +++ b/shared/src/i18n/de/dashboard.ts @@ -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?', diff --git a/shared/src/i18n/en/dashboard.ts b/shared/src/i18n/en/dashboard.ts index 36e4c7fc8..bce9a8446 100644 --- a/shared/src/i18n/en/dashboard.ts +++ b/shared/src/i18n/en/dashboard.ts @@ -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', diff --git a/shared/src/i18n/es/dashboard.ts b/shared/src/i18n/es/dashboard.ts index 81df36070..90520a60e 100644 --- a/shared/src/i18n/es/dashboard.ts +++ b/shared/src/i18n/es/dashboard.ts @@ -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?', diff --git a/shared/src/i18n/fr/dashboard.ts b/shared/src/i18n/fr/dashboard.ts index 5d03f4f25..6caf781c0 100644 --- a/shared/src/i18n/fr/dashboard.ts +++ b/shared/src/i18n/fr/dashboard.ts @@ -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 ?', diff --git a/shared/src/i18n/gr/dashboard.ts b/shared/src/i18n/gr/dashboard.ts index f2cd0d479..8fddf5375 100644 --- a/shared/src/i18n/gr/dashboard.ts +++ b/shared/src/i18n/gr/dashboard.ts @@ -99,6 +99,7 @@ const dashboard: TranslationStrings = { 'dashboard.useUnsplashPhoto': 'Χρήση φωτογραφίας Unsplash από {photographer}', 'dashboard.titleRequired': 'Ο τίτλος είναι υποχρεωτικός', 'dashboard.endDateError': 'Η ημερομηνία λήξης πρέπει να είναι μετά την ημερομηνία έναρξης', + 'dashboard.tripTooLong': 'Ένα ταξίδι μπορεί να διαρκεί το πολύ {days} ημέρες', 'dashboard.dateShiftTitle': 'Νέα ημερομηνία έναρξης', 'dashboard.dateShiftIntro': 'Αλλάξατε την ημερομηνία έναρξης αυτού του ταξιδιού. Πώς θέλετε να ακολουθήσουν τα σχέδιά σας τις νέες ημερομηνίες;', diff --git a/shared/src/i18n/hu/dashboard.ts b/shared/src/i18n/hu/dashboard.ts index a73e0ba7c..88a9e37f3 100644 --- a/shared/src/i18n/hu/dashboard.ts +++ b/shared/src/i18n/hu/dashboard.ts @@ -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', diff --git a/shared/src/i18n/id/dashboard.ts b/shared/src/i18n/id/dashboard.ts index 763dd42e3..0bcc208ef 100644 --- a/shared/src/i18n/id/dashboard.ts +++ b/shared/src/i18n/id/dashboard.ts @@ -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?', diff --git a/shared/src/i18n/it/dashboard.ts b/shared/src/i18n/it/dashboard.ts index a50fabfc4..58f25a44f 100644 --- a/shared/src/i18n/it/dashboard.ts +++ b/shared/src/i18n/it/dashboard.ts @@ -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?', diff --git a/shared/src/i18n/ja/dashboard.ts b/shared/src/i18n/ja/dashboard.ts index 1a16807b7..35760b586 100644 --- a/shared/src/i18n/ja/dashboard.ts +++ b/shared/src/i18n/ja/dashboard.ts @@ -102,6 +102,7 @@ const dashboard: TranslationStrings = { 'dashboard.useUnsplashPhoto': '{photographer}さんのUnsplash写真を使用', 'dashboard.titleRequired': 'タイトルは必須です', 'dashboard.endDateError': '終了日は開始日より後にしてください', + 'dashboard.tripTooLong': '旅行の期間は最大{days}日までです', 'dashboard.dateShiftTitle': '新しい開始日', 'dashboard.dateShiftIntro': '旅行の開始日が変更されました。予定を新しい日程にどのように合わせますか?', 'dashboard.dateShiftKeepBookings': '予約を元の日付のままにする', diff --git a/shared/src/i18n/ko/dashboard.ts b/shared/src/i18n/ko/dashboard.ts index 56be14dab..314c67a8c 100644 --- a/shared/src/i18n/ko/dashboard.ts +++ b/shared/src/i18n/ko/dashboard.ts @@ -102,6 +102,7 @@ const dashboard: TranslationStrings = { 'dashboard.useUnsplashPhoto': '{photographer}님의 Unsplash 사진 사용', 'dashboard.titleRequired': '제목을 입력하세요', 'dashboard.endDateError': '종료일은 시작일 이후여야 합니다', + 'dashboard.tripTooLong': '여행 기간은 최대 {days}일까지입니다', 'dashboard.dateShiftTitle': '새 시작일', 'dashboard.dateShiftIntro': '여행 시작일이 변경되었습니다. 계획을 새 날짜에 어떻게 맞출까요?', 'dashboard.dateShiftKeepBookings': '예약은 원래 날짜에 유지', diff --git a/shared/src/i18n/nl/dashboard.ts b/shared/src/i18n/nl/dashboard.ts index 21d1c6374..650a98323 100644 --- a/shared/src/i18n/nl/dashboard.ts +++ b/shared/src/i18n/nl/dashboard.ts @@ -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?', diff --git a/shared/src/i18n/pl/dashboard.ts b/shared/src/i18n/pl/dashboard.ts index c8ba9972c..89298608b 100644 --- a/shared/src/i18n/pl/dashboard.ts +++ b/shared/src/i18n/pl/dashboard.ts @@ -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?', diff --git a/shared/src/i18n/ru/dashboard.ts b/shared/src/i18n/ru/dashboard.ts index 7a7b5040d..c01dd8fbe 100644 --- a/shared/src/i18n/ru/dashboard.ts +++ b/shared/src/i18n/ru/dashboard.ts @@ -87,6 +87,7 @@ const dashboard: TranslationStrings = { 'dashboard.useUnsplashPhoto': 'Использовать фото Unsplash от {photographer}', 'dashboard.titleRequired': 'Название обязательно', 'dashboard.endDateError': 'Дата окончания должна быть позже даты начала', + 'dashboard.tripTooLong': 'Поездка может длиться не более {days} дней', 'dashboard.dateShiftTitle': 'Новая дата начала', 'dashboard.dateShiftIntro': 'Вы изменили дату начала поездки. Как перенести ваши планы на новые даты?', 'dashboard.dateShiftKeepBookings': 'Оставить бронирования на своих датах', diff --git a/shared/src/i18n/sv/dashboard.ts b/shared/src/i18n/sv/dashboard.ts index 1bc4b8a83..ef19adf19 100644 --- a/shared/src/i18n/sv/dashboard.ts +++ b/shared/src/i18n/sv/dashboard.ts @@ -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', diff --git a/shared/src/i18n/tr/dashboard.ts b/shared/src/i18n/tr/dashboard.ts index 37f6391b1..a191dee85 100644 --- a/shared/src/i18n/tr/dashboard.ts +++ b/shared/src/i18n/tr/dashboard.ts @@ -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?', diff --git a/shared/src/i18n/uk/dashboard.ts b/shared/src/i18n/uk/dashboard.ts index 80c60ff8b..566fbae78 100644 --- a/shared/src/i18n/uk/dashboard.ts +++ b/shared/src/i18n/uk/dashboard.ts @@ -103,6 +103,7 @@ const dashboard: TranslationStrings = { 'dashboard.useUnsplashPhoto': 'Використати фото Unsplash від {photographer}', 'dashboard.titleRequired': "Назва обов'язкова", 'dashboard.endDateError': 'Дата закінчення повинна бути пізніше дати початку', + 'dashboard.tripTooLong': 'Подорож може тривати не більше {days} днів', 'dashboard.dateShiftTitle': 'Нова дата початку', 'dashboard.dateShiftIntro': 'Ви змінили дату початку поїздки. Як ваші плани мають перейти на нові дати?', 'dashboard.dateShiftKeepBookings': 'Залишити бронювання на своїх датах', diff --git a/shared/src/i18n/vi/dashboard.ts b/shared/src/i18n/vi/dashboard.ts index 73792eadc..1c09460f3 100644 --- a/shared/src/i18n/vi/dashboard.ts +++ b/shared/src/i18n/vi/dashboard.ts @@ -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?', diff --git a/shared/src/i18n/zh-TW/dashboard.ts b/shared/src/i18n/zh-TW/dashboard.ts index ae09d0fc0..3a5a1e41e 100644 --- a/shared/src/i18n/zh-TW/dashboard.ts +++ b/shared/src/i18n/zh-TW/dashboard.ts @@ -87,6 +87,7 @@ const dashboard: TranslationStrings = { 'dashboard.useUnsplashPhoto': '使用 {photographer} 的 Unsplash 照片', 'dashboard.titleRequired': '標題為必填項', 'dashboard.endDateError': '結束日期必須晚於開始日期', + 'dashboard.tripTooLong': '行程最長為 {days} 天', 'dashboard.dateShiftTitle': '新的開始日期', 'dashboard.dateShiftIntro': '你變更了旅行的開始時間。你的計劃要如何配合新日期?', 'dashboard.dateShiftKeepBookings': '預訂保留在原日期', diff --git a/shared/src/i18n/zh/dashboard.ts b/shared/src/i18n/zh/dashboard.ts index 49306ca04..6083d29a9 100644 --- a/shared/src/i18n/zh/dashboard.ts +++ b/shared/src/i18n/zh/dashboard.ts @@ -87,6 +87,7 @@ const dashboard: TranslationStrings = { 'dashboard.useUnsplashPhoto': '使用 {photographer} 的 Unsplash 照片', 'dashboard.titleRequired': '标题为必填项', 'dashboard.endDateError': '结束日期必须晚于开始日期', + 'dashboard.tripTooLong': '行程最长为 {days} 天', 'dashboard.dateShiftTitle': '新的开始日期', 'dashboard.dateShiftIntro': '您更改了旅行的开始时间。您的计划应如何适应新的日期?', 'dashboard.dateShiftKeepBookings': '保留预订的原定日期', diff --git a/shared/src/trip/trip.schema.spec.ts b/shared/src/trip/trip.schema.spec.ts index 07e5706a6..ba84982d3 100644 --- a/shared/src/trip/trip.schema.spec.ts +++ b/shared/src/trip/trip.schema.spec.ts @@ -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); diff --git a/shared/src/trip/trip.schema.ts b/shared/src/trip/trip.schema.ts index c47a0fb80..69906c63b 100644 --- a/shared/src/trip/trip.schema.ts +++ b/shared/src/trip/trip.schema.ts @@ -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` diff --git a/wiki/Creating-a-Trip.md b/wiki/Creating-a-Trip.md index 51b0315c0..a6db49439 100644 --- a/wiki/Creating-a-Trip.md +++ b/wiki/Creating-a-Trip.md @@ -20,9 +20,9 @@ A short free-text description. It is not shown on the trip card or on the Spotli ### Dates -Set a **Start date** and **End date** using the date picker. The day count is calculated automatically when both are set. +Set a **Start date** and **End date** using the date picker. The day count is calculated automatically when both are set. A trip can span up to **999 days**. -On desktop, if you leave **both** dates empty, a separate **Day count** field appears. Enter a number between **1 and 365** to create a date-less itinerary with a fixed number of days. The mobile sheet has no such field: a date-less trip created there always starts with 7 days. +On desktop, if you leave **both** dates empty, a separate **Day count** field appears. Enter a number between **1 and 999** to create a date-less itinerary with a fixed number of days. The mobile sheet has no such field: a date-less trip created there always starts with 7 days. Only the **start** date is linked to the other one. Picking a start date fills the end date to match it, and moving the start date of an already-dated trip shifts the end date so the previous duration is kept. Nothing links them the other way round: you can pick an end date on its own, and the picker's **✕** button clears either field afterwards. A trip can therefore be saved with both dates, with only a start date, with only an end date, or with neither. A trip left with exactly one date gets an undated day grid (7 days by default), just like a date-less one.