feat(trips): shortening a trip says first which days go and what is on them

The trip dialog on the desktop and the trip sheet on a phone now ask before a save whose new dates remove days with something on them. The question names those days the way the day list does and lists what goes with them in the same rows the delete day question uses: planned places stay in the place list, notes and day texts are deleted, bookings stay under Bookings, and a stay that checks in or out on a removed day goes as a whole while its booking and expense remain, shown in the danger tone. A moved start gets both questions in one step, with the booking hint following the chosen shift mode, and a save that only drops empty days asks nothing.

The rule that lays the days out moved into shared as planDayGrid, with resolveDayGridRange deciding whether the rows are rebuilt at all. generateDays on the server now carries that plan out in one transaction, so the warning names exactly the days the save takes, and a seeded fuzz test holds the new rebuild to the old one over 300 random day grids. The update_trip tool says in its description what shortening takes and lists removed_days in its answer.

Alongside, the dialog sends a day count only when it changed, so a stale count no longer trims a day just added, stays are reloaded after every date change including the one made on a phone, and the date shift tip now points to adding a day under Reorder days.
This commit is contained in:
Maurice
2026-09-23 19:54:09 +02:00
parent b56f335978
commit 1b63285ad0
50 changed files with 2122 additions and 231 deletions
@@ -0,0 +1,73 @@
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import DayImpactList from '../shared/DayImpactList'
import { dayChips, shrinkTripLines, type ShiftMode } from '../../utils/dayImpactLines'
import type { RangeCheck } from '../../hooks/useTripRangeGuard'
interface TripDateReviewProps {
/** The start of a dated trip moved: ask how bookings follow (#1288). */
askShift: boolean
shiftMode: ShiftMode
onShiftMode: (mode: ShiftMode) => void
/** What the new dates remove, if anything worth a word. */
removal: RangeCheck
error?: string
}
/**
* The step the trip dialog puts between a change of dates and the save: how
* bookings follow a moved start, and which days the new range removes with what
* is on them. Either part can stand alone. The list's booking hint follows the
* shift mode picked above it, because the two modes leave those bookings in
* different places.
*/
export default function TripDateReview({ askShift, shiftMode, onShiftMode, removal, error }: TripDateReviewProps) {
const { t } = useTranslation()
const modes: { mode: ShiftMode; label: string; desc: string }[] = [
{ mode: 'keep_bookings', label: t('dashboard.dateShiftKeepBookings'), desc: t('dashboard.dateShiftKeepBookingsDesc') },
{ mode: 'shift_all', label: t('dashboard.dateShiftAll'), desc: t('dashboard.dateShiftAllDesc') },
]
return (
<div className="space-y-3">
{error && (
<div className="p-3 bg-danger-soft border border-danger/30 rounded-xl text-body text-danger">{error}</div>
)}
{askShift && (
<>
<p className="text-body text-content-secondary">{t('dashboard.dateShiftIntro')}</p>
{modes.map(({ mode, label, desc }) => (
<label key={mode}
className={`flex items-start gap-3 p-3 border rounded-xl cursor-pointer transition-colors ${shiftMode === mode ? 'border-accent bg-surface-selected' : 'border-edge hover:bg-surface-hover'}`}>
<input type="radio" name="date_shift_mode" value={mode} checked={shiftMode === mode}
onChange={() => onShiftMode(mode)} className="mt-1 accent-[var(--accent)]" />
<span className="block text-body font-medium text-content">
{label}
<span className="block text-body font-normal text-content-muted mt-0.5">{desc}</span>
</span>
</label>
))}
</>
)}
{removal === 'unknown' && (
<div role="alert" className="flex items-start gap-3 rounded-xl border border-edge-faint bg-surface-secondary p-3">
<span className="mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-danger-soft text-danger">
<AlertTriangle size={15} strokeWidth={2} aria-hidden="true" />
</span>
<p className="text-body text-content">{t('dashboard.shrinkUnknown')}</p>
</div>
)}
{removal && removal !== 'unknown' && (
<div className={askShift ? 'border-t border-edge-faint pt-3' : ''}>
<p className="text-body text-content-secondary">{t('dashboard.shrinkIntro')}</p>
<DayImpactList
lines={shrinkTripLines(removal, t, shiftMode)}
days={dayChips(removal.dayLabels, t)}
label={t('dashboard.shrinkTitle')}
/>
</div>
)}
{askShift && <p className="text-caption text-content-faint">{t('dashboard.dateShiftHint')}</p>}
</div>
)
}
@@ -1,4 +1,4 @@
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-084
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-095
import type { Mock } from 'vitest';
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -8,9 +8,9 @@ import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import { buildUser, buildTrip, buildDay, buildAssignment, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import type { Trip } from '../../types';
import type { Accommodation, Reservation, Trip } from '../../types';
import { MAX_TRIP_DAYS } from '@trek/shared';
import TripFormModal from './TripFormModal';
@@ -1290,4 +1290,169 @@ describe('TripFormModal', () => {
expect.objectContaining({ title: 'Renamed', start_date: '2020-01-01', end_date: '2030-01-01' })
));
});
// ── Shortening: the warning before days go ────────────────────────────────
/** A week in October with a place on its last two days, and the trip's bookings and stays. */
const shrinkableTrip = () => buildTrip({ id: 1, title: 'Coast week', start_date: '2026-10-01', end_date: '2026-10-07' });
const seedTripDays = (extras: { reservations?: Reservation[]; accommodations?: Accommodation[]; empty?: boolean } = {}) => {
const days = Array.from({ length: 7 }, (_, i) => buildDay({
id: 300 + i,
trip_id: 1,
day_number: i + 1,
date: `2026-10-0${i + 1}`,
assignments: !extras.empty && i >= 5 ? [buildAssignment({ day_id: 300 + i, place: buildPlace({ id: 400 + i }) })] : [],
}));
server.use(
http.get('/api/trips/:id/days', () => HttpResponse.json({ days })),
http.get('/api/trips/:id/reservations', () => HttpResponse.json({ reservations: extras.reservations ?? [] })),
http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ accommodations: extras.accommodations ?? [] })),
);
};
const changeEndDate = async (user: ReturnType<typeof userEvent.setup>, iso: string) => {
await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[1]);
const input = screen.getByPlaceholderText('DD.MM.YYYY');
fireEvent.change(input, { target: { value: iso } });
fireEvent.keyDown(input, { key: 'Enter' });
};
it('FE-COMP-TRIPFORM-088: an earlier end asks first and names the days it takes, with what is on them', async () => {
seedTripDays({
accommodations: [{ id: 9, trip_id: 1, start_day_id: 304, end_day_id: 306, place_name: 'Harbour Hotel' } as Accommodation],
reservations: [buildReservation({ id: 70, day_id: 304, type: 'hotel', title: 'Harbour, 2 nights', accommodation_id: 9 })],
});
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={onSave} />);
await changeEndDate(user, '2026-10-05');
await user.click(screen.getByRole('button', { name: /Update/i }));
const list = await screen.findByRole('list', { name: 'Remove days?' });
expect(screen.getAllByText('Remove days?').length).toBeGreaterThan(0);
expect(screen.getByText('Saving the new dates removes these days:')).toBeInTheDocument();
expect(within(list).getByText('Tue, Oct 6')).toBeInTheDocument();
expect(within(list).getByText('Wed, Oct 7')).toBeInTheDocument();
expect(within(list).getByText('Stay at Harbour Hotel').closest('li')).toHaveAttribute('data-tone', 'danger');
expect(within(list).getByText(/Its booking "Harbour, 2 nights" and its expense stay under Bookings/)).toBeInTheDocument();
expect(within(list).getByText('Planned places: 2')).toBeInTheDocument();
// No start moved, so no shift question.
expect(screen.queryByText('Keep bookings on their dates')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove days and save' })).toHaveClass('bg-danger');
expect(onSave).not.toHaveBeenCalled();
});
it('FE-COMP-TRIPFORM-089: a later start that also shortens asks both in one step', async () => {
seedTripDays();
const user = userEvent.setup();
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={vi.fn()} />);
// Moving the start keeps the length, so the end is pulled back afterwards.
await changeStartDate(user, '2026-10-03');
await changeEndDate(user, '2026-10-07');
await user.click(screen.getByRole('button', { name: /Update/i }));
await screen.findByText('Keep bookings on their dates');
expect(screen.getByText('New start date')).toBeInTheDocument();
const list = screen.getByRole('list', { name: 'Remove days?' });
expect(within(list).getByText('The last days go, not the first')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove days and save' })).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-090: a longer trip, or a shorter one that loses only empty days, saves without asking', async () => {
seedTripDays({ empty: true });
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={onSave} />);
await changeEndDate(user, '2026-10-05');
await user.click(screen.getByRole('button', { name: /Update/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ end_date: '2026-10-05' })));
expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ date_shift_mode: expect.anything() }));
expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-091: confirming saves the held payload, with the shift mode only when it was asked', async () => {
seedTripDays();
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={onSave} />);
await changeEndDate(user, '2026-10-05');
await user.click(screen.getByRole('button', { name: /Update/i }));
await user.click(await screen.findByRole('button', { name: 'Remove days and save' }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ start_date: '2026-10-01', end_date: '2026-10-05' }));
expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ date_shift_mode: expect.anything() }));
});
it('FE-COMP-TRIPFORM-092: the booking hint follows the shift mode picked above the list', async () => {
seedTripDays({ reservations: [buildReservation({ id: 71, day_id: 306, title: 'Ferry' })] });
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={onSave} />);
await changeStartDate(user, '2026-10-02');
await changeEndDate(user, '2026-10-06');
await user.click(screen.getByRole('button', { name: /Update/i }));
const list = await screen.findByRole('list', { name: 'Remove days?' });
expect(within(list).getByText(/One whose date is still part of the trip goes back onto that day/)).toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: /Shift everything/i }));
expect(within(list).getByText('They stay under Bookings, without a day.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Remove days and save' }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ date_shift_mode: 'shift_all' })));
});
it('FE-COMP-TRIPFORM-093: Back from the warning returns to the form and saves nothing', async () => {
seedTripDays();
const user = userEvent.setup();
const onSave = vi.fn();
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={onSave} />);
await changeEndDate(user, '2026-10-05');
await user.click(screen.getByRole('button', { name: /Update/i }));
await screen.findByRole('list', { name: 'Remove days?' });
await user.click(screen.getByRole('button', { name: /Back/i }));
await waitFor(() => expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument());
expect(screen.getByDisplayValue('Coast week')).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('FE-COMP-TRIPFORM-094: when the days cannot be read it still warns, in general terms', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
server.use(http.get('/api/trips/:id/days', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
const user = userEvent.setup();
render(<TripFormModal {...defaultProps} trip={shrinkableTrip()} onSave={vi.fn()} />);
await changeEndDate(user, '2026-10-05');
await user.click(screen.getByRole('button', { name: /Update/i }));
expect(await screen.findByRole('alert')).toHaveTextContent('The days of this trip could not be checked.');
expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove days and save' })).toBeInTheDocument();
spy.mockRestore();
});
it('FE-COMP-TRIPFORM-095: a trip without dates sends its day count only when it changed', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue({});
const undated = buildTrip({ id: 1, title: 'Someday', start_date: null, end_date: null, day_count: 4 });
const { unmount } = render(<TripFormModal {...defaultProps} trip={undated} onSave={onSave} />);
await user.click(screen.getByRole('button', { name: /Update/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).not.toHaveProperty('day_count');
unmount();
render(<TripFormModal {...defaultProps} trip={undated} onSave={onSave} />);
const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement;
fireEvent.change(dayInput, { target: { value: '6' } });
await user.click(screen.getByRole('button', { name: /Update/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(2));
expect(onSave.mock.calls[1][0]).toMatchObject({ day_count: 6 });
});
});
+61 -45
View File
@@ -14,8 +14,23 @@ import { getApiErrorMessage, type Trip } from '../../types'
import { MAX_TRIP_DAYS, tripSpanDays, type TripCreateRequest } from '@trek/shared'
import { NumericInput } from '../shared/NumericInput'
import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants'
import TripDateReview from './TripDateReview'
import { useTripRangeGuard, type RangeCheck } from '../../hooks/useTripRangeGuard'
import type { ShiftMode } from '../../utils/dayImpactLines'
type DateShiftMode = 'keep_bookings' | 'shift_all'
type DateShiftMode = ShiftMode
type TripPayload = TripCreateRequest & { date_shift_mode?: DateShiftMode }
/**
* A save held back for a look first: the start of a dated trip moved and the
* bookings need a direction (#1288), or the new range removes days with
* something on them. The payload waits here until the traveller confirms.
*/
interface PendingReview {
payload: TripPayload
askShift: boolean
removal: RangeCheck
}
interface TripFormModalProps {
isOpen: boolean
@@ -80,10 +95,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([])
const [memberSelectValue, setMemberSelectValue] = useState('')
// Set when a start-date change on a dated trip needs the user to pick how the
// itinerary follows the new dates (#1288); holds the payload awaiting that choice.
const [pendingDateShift, setPendingDateShift] = useState<(TripCreateRequest & { date_shift_mode?: DateShiftMode }) | null>(null)
const [pendingReview, setPendingReview] = useState<PendingReview | null>(null)
const [dateShiftMode, setDateShiftMode] = useState<DateShiftMode>('keep_bookings')
const rangeGuard = useTripRangeGuard()
const busy = isLoading || rangeGuard.checking
useEffect(() => {
if (trip) {
@@ -115,7 +130,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
setCoverSearchResults([])
setCoverSearchError('')
setSelectedMembers([])
setPendingDateShift(null)
setPendingReview(null)
setDateShiftMode('keep_bookings')
setError('')
setExistingMembers([])
@@ -162,28 +177,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
setError(t('dashboard.dayCountRequired')); return
}
}
const payload: TripCreateRequest & { date_shift_mode?: DateShiftMode } = {
// The day count goes along only when it says something new. The trip's own
// count goes stale in the planner (adding a day does not reload the trip),
// and sending the stale number trims the day just added off a trip that was
// only renamed. On a trip left without dates the server keeps its days anyway.
const dayCount = Number(formData.day_count)
const sendDayCount = !formData.start_date && !formData.end_date && (!isEditing || dayCount !== trip?.day_count)
const payload: TripPayload = {
title: formData.title.trim(),
description: formData.description.trim() || null,
start_date: formData.start_date || null,
end_date: formData.end_date || null,
currency: formData.currency,
reminder_days: formData.reminder_days,
...(!formData.start_date && !formData.end_date ? { day_count: Number(formData.day_count) } : {}),
...(sendDayCount ? { day_count: dayCount } : {}),
}
// Moving the start of a dated trip shifts the whole day grid, so let the user
// choose how bookings follow before anything is saved (#1288). End-date-only
// changes don't shift days and dateless transitions have nothing to shift.
if (isEditing && trip?.start_date && trip?.end_date
&& payload.start_date && payload.end_date && payload.start_date !== trip.start_date) {
const askShift = !!(isEditing && trip?.start_date && trip?.end_date
&& payload.start_date && payload.end_date && payload.start_date !== trip.start_date)
// And say which days the new range removes, with what is on them, before
// they are gone: the server takes the last days by position.
const removal = isEditing && trip ? await rangeGuard.check(trip, payload) : null
if (askShift || removal) {
setDateShiftMode('keep_bookings')
setPendingDateShift(payload)
setPendingReview({ payload, askShift, removal })
return
}
await performSave(payload)
}
const performSave = async (payload: TripCreateRequest & { date_shift_mode?: DateShiftMode }) => {
const performSave = async (payload: TripPayload) => {
setIsLoading(true)
try {
const result = await onSave(payload)
@@ -368,6 +393,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const labelCls = "flex items-center gap-1 text-caption font-semibold uppercase tracking-[0.14em] text-content-faint mb-2"
const ghostBtnCls = "px-4 py-2.5 text-body font-medium text-content-secondary hover:text-content border border-edge rounded-xl hover:bg-surface-hover transition-colors"
const primaryBtnCls = "px-5 py-2.5 text-body font-medium bg-accent hover:bg-accent-hover disabled:opacity-50 text-accent-text rounded-xl shadow-card transition-colors flex items-center gap-2"
const dangerBtnCls = "px-5 py-2.5 text-body font-medium bg-danger hover:opacity-90 disabled:opacity-50 text-white rounded-xl shadow-card transition-opacity flex items-center gap-2"
/* Two columns once there is room for them: the form had grown to eight stacked
blocks and the create button sat a full screen below the title. Left is what
the trip looks like, right is when and with whom. One column below md. */
@@ -378,22 +404,26 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<Modal
isOpen={isOpen}
onClose={onClose}
title={pendingDateShift ? t('dashboard.dateShiftTitle') : isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
/* The date-shift step is two radio buttons and a sentence — it would look
title={pendingReview
? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle')
: isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
/* The review step is a few radio buttons and a short list; it would look
lost across the width the form itself needs. */
size={pendingDateShift ? 'md' : '2xl'}
size={pendingReview ? 'md' : '2xl'}
footer={
<div className="flex gap-3 justify-end">
{pendingDateShift ? (
{pendingReview ? (
<>
<button type="button" onClick={() => setPendingDateShift(null)} disabled={isLoading} className={ghostBtnCls}>
<button type="button" onClick={() => setPendingReview(null)} disabled={busy} className={ghostBtnCls}>
{t('common.back')}
</button>
<button type="button" onClick={() => performSave({ ...pendingDateShift, date_shift_mode: dateShiftMode })} disabled={isLoading}
className={primaryBtnCls}>
{/* Removing days with something on them is the one save here that
loses work, so it asks in the danger tone and says so. */}
<button type="button" onClick={() => performSave(pendingReview.askShift ? { ...pendingReview.payload, date_shift_mode: dateShiftMode } : pendingReview.payload)} disabled={busy}
className={pendingReview.removal ? dangerBtnCls : primaryBtnCls}>
{isLoading
? <><div className="w-4 h-4 border-2 border-accent-text/30 border-t-accent-text rounded-full animate-spin" />{t('common.saving')}</>
: t('common.update')}
? <><div className={`w-4 h-4 border-2 rounded-full animate-spin ${pendingReview.removal ? 'border-white/30 border-t-white' : 'border-accent-text/30 border-t-accent-text'}`} />{t('common.saving')}</>
: t(pendingReview.removal ? 'dashboard.shrinkConfirm' : 'common.update')}
</button>
</>
) : (
@@ -401,8 +431,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<button type="button" onClick={onClose} className={ghostBtnCls}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isLoading} className={primaryBtnCls}>
{isLoading
<button type="button" onClick={handleSubmit} disabled={busy} className={primaryBtnCls}>
{busy
? <><div className="w-4 h-4 border-2 border-accent-text/30 border-t-accent-text rounded-full animate-spin" />{t('common.saving')}</>
: isEditing ? t('common.update') : t('dashboard.createTrip')}
</button>
@@ -411,30 +441,16 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</div>
}
>
{pendingDateShift && (
<div className="space-y-3">
{error && (
<div className="p-3 bg-danger-soft border border-danger/30 rounded-xl text-body text-danger">{error}</div>
)}
<p className="text-body text-content-secondary">{t('dashboard.dateShiftIntro')}</p>
{([
{ mode: 'keep_bookings' as DateShiftMode, label: t('dashboard.dateShiftKeepBookings'), desc: t('dashboard.dateShiftKeepBookingsDesc') },
{ mode: 'shift_all' as DateShiftMode, label: t('dashboard.dateShiftAll'), desc: t('dashboard.dateShiftAllDesc') },
]).map(({ mode, label, desc }) => (
<label key={mode}
className={`flex items-start gap-3 p-3 border rounded-xl cursor-pointer transition-colors ${dateShiftMode === mode ? 'border-accent bg-surface-selected' : 'border-edge hover:bg-surface-hover'}`}>
<input type="radio" name="date_shift_mode" value={mode} checked={dateShiftMode === mode}
onChange={() => setDateShiftMode(mode)} className="mt-1 accent-[var(--accent)]" />
<span className="block text-body font-medium text-content">
{label}
<span className="block text-body font-normal text-content-muted mt-0.5">{desc}</span>
</span>
</label>
))}
<p className="text-caption text-content-faint">{t('dashboard.dateShiftHint')}</p>
</div>
{pendingReview && (
<TripDateReview
askShift={pendingReview.askShift}
shiftMode={dateShiftMode}
onShiftMode={setDateShiftMode}
removal={pendingReview.removal}
error={error}
/>
)}
<form onSubmit={handleSubmit} className={pendingDateShift ? 'hidden' : 'space-y-4'} onPaste={handlePaste}>
<form onSubmit={handleSubmit} className={pendingReview ? 'hidden' : 'space-y-4'} onPaste={handlePaste}>
{error && (
<div className="p-3 bg-danger-soft border border-danger/30 rounded-xl text-body text-danger">{error}</div>
)}
@@ -1,4 +1,4 @@
// FE-COMP-DAYIMPACT-001 to FE-COMP-DAYIMPACT-003
// FE-COMP-DAYIMPACT-001 to FE-COMP-DAYIMPACT-004
import { describe, it, expect } from 'vitest'
import { BedDouble, MapPin } from 'lucide-react'
import { render, screen } from '../../../tests/helpers/render'
@@ -46,4 +46,21 @@ describe('DayImpactList', () => {
expect(rows[0].className).not.toContain('border-t')
expect(rows[1].className).toContain('border-t')
})
it('FE-COMP-DAYIMPACT-004: the days themselves lead the list as chips, in both skins', () => {
render(<DayImpactList lines={lines} days={['Fri, Oct 9', 'Sat, Oct 10', '+2 more']} label="desk" />)
const desk = screen.getByRole('list', { name: 'desk' })
const [daysRow, stay] = Array.from(desk.querySelectorAll('li'))
expect(daysRow).toHaveAttribute('data-kind', 'days')
expect(Array.from(daysRow.querySelectorAll('div > span')).map(s => s.textContent)).toEqual(['Fri, Oct 9', 'Sat, Oct 10', '+2 more'])
expect(daysRow.querySelector('span')).toHaveClass('bg-warning-soft')
expect(stay).toHaveTextContent('Stay at Harbour Hotel')
render(<MDayImpactList lines={lines} days={['Fri, Oct 9']} label="phone" />)
const phoneRows = screen.getByRole('list', { name: 'phone' }).querySelectorAll('li')
expect(phoneRows).toHaveLength(3)
expect(phoneRows[0].className).not.toContain('border-t')
// The first content row sits under the day chips, so it gets the hairline.
expect(phoneRows[1].className).toContain('border-t')
})
})
+22 -2
View File
@@ -1,3 +1,4 @@
import { CalendarMinus } from 'lucide-react'
import type { ImpactLine, ImpactTone } from '../../utils/dayImpactLines'
/** The class names one shell draws the list with. */
@@ -7,6 +8,8 @@ export interface ImpactListSkin {
/** Between two rows; the desktop card divides them itself. */
divider: string
chip: string
/** One removed day, named in the row above the content rows. */
dayChip: string
iconSize: number
text: string
hint: string
@@ -19,6 +22,7 @@ const DESKTOP: ImpactListSkin = {
row: 'flex items-start gap-3 px-3 py-2.5',
divider: '',
chip: 'mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg',
dayChip: 'rounded-full border border-edge-faint bg-surface px-2.5 py-0.5 text-caption font-medium text-content-secondary',
iconSize: 15,
text: 'text-body font-medium leading-snug',
hint: 'mt-0.5 text-caption text-content-muted',
@@ -33,6 +37,11 @@ const DESKTOP: ImpactListSkin = {
interface DayImpactListProps {
lines: ImpactLine[]
/**
* The days themselves, as chips in a first row: when several days go at
* once, the list names them before it says what is on them.
*/
days?: string[]
/** Names the list for assistive tech; the dialog title usually says it already. */
label?: string
/** The phone passes its own; the default is the desktop card. */
@@ -46,12 +55,23 @@ interface DayImpactListProps {
* skin, so the desktop dialog and the phone sheet share this markup and differ
* only in their tokens.
*/
export default function DayImpactList({ lines, label, skin = DESKTOP }: DayImpactListProps) {
export default function DayImpactList({ lines, days = [], label, skin = DESKTOP }: DayImpactListProps) {
if (lines.length === 0) return null
const offset = days.length > 0 ? 1 : 0
return (
<ul aria-label={label} className={skin.list}>
{offset > 0 && (
<li data-kind="days" className={skin.row}>
<span className={`${skin.chip} ${skin.chipTone.warning}`}>
<CalendarMinus size={skin.iconSize} strokeWidth={2} aria-hidden="true" />
</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-1.5 pt-1">
{days.map((name, i) => <span key={`${i}-${name}`} className={skin.dayChip}>{name}</span>)}
</div>
</li>
)}
{lines.map(({ key, icon: Icon, text, hint, tone }, i) => (
<li key={key} data-tone={tone} className={`${skin.row} ${i > 0 ? skin.divider : ''}`.trim()}>
<li key={key} data-tone={tone} className={`${skin.row} ${i + offset > 0 ? skin.divider : ''}`.trim()}>
<span className={`${skin.chip} ${skin.chipTone[tone]}`}>
<Icon size={skin.iconSize} strokeWidth={2} aria-hidden="true" />
</span>
@@ -0,0 +1,93 @@
/**
* FE-HOOK-RANGEGUARD-001 to FE-HOOK-RANGEGUARD-005: the question before a trip
* dialog saves new dates. The desktop dialog and the phone sheet both render
* what this answers, so what it reads, when it reads nothing, and what it says
* when it cannot read are pinned here once. The repos are mocked; the layout
* rule is the real planDayGrid from shared, the one the server runs.
*/
import { createElement, type ReactNode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { TranslationProvider } from '../i18n/TranslationContext'
import { buildAssignment, buildDay, buildPlace } from '../../tests/helpers/factories'
import type { Accommodation, Day, Reservation } from '../types'
import { useTripRangeGuard, type RangeCheck, type RangeRemoval } from './useTripRangeGuard'
const repos = {
days: vi.fn<() => Promise<{ days: Day[] }>>(),
reservations: vi.fn<() => Promise<{ reservations: Reservation[] }>>(),
accommodations: vi.fn<() => Promise<{ accommodations: Accommodation[] }>>(),
}
vi.mock('../repo/dayRepo', () => ({ dayRepo: { list: () => repos.days() } }))
vi.mock('../repo/reservationRepo', () => ({ reservationRepo: { list: () => repos.reservations() } }))
vi.mock('../repo/accommodationRepo', () => ({ accommodationRepo: { list: () => repos.accommodations() } }))
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children)
const trip = { id: 7, start_date: '2026-10-01', end_date: '2026-10-05' }
/** Five dated days; the ones named get a place. */
function fiveDays(planned: number[] = []): Day[] {
return Array.from({ length: 5 }, (_, i) => buildDay({
id: i + 1,
day_number: i + 1,
date: `2026-10-0${i + 1}`,
assignments: planned.includes(i + 1) ? [buildAssignment({ day_id: i + 1, place: buildPlace({ id: 90 + i }) })] : [],
}))
}
async function ask(payload: Parameters<ReturnType<typeof useTripRangeGuard>['check']>[1]): Promise<RangeCheck> {
const { result } = renderHook(() => useTripRangeGuard(), { wrapper })
let answer: RangeCheck = null
await act(async () => { answer = await result.current.check(trip, payload) })
expect(result.current.checking).toBe(false)
return answer
}
beforeEach(() => {
repos.days.mockReset().mockResolvedValue({ days: fiveDays([4, 5]) })
repos.reservations.mockReset().mockResolvedValue({ reservations: [] })
repos.accommodations.mockReset().mockResolvedValue({ accommodations: [] })
})
describe('useTripRangeGuard', () => {
it('FE-HOOK-RANGEGUARD-001: a save that keeps the dates is answered without reading anything', async () => {
const rename = { title: 'Renamed', start_date: trip.start_date, end_date: trip.end_date }
expect(await ask(rename)).toBeNull()
expect(await ask({})).toBeNull()
expect(repos.days).not.toHaveBeenCalled()
expect(repos.reservations).not.toHaveBeenCalled()
expect(repos.accommodations).not.toHaveBeenCalled()
})
it('FE-HOOK-RANGEGUARD-002: an earlier end reads the three repos and names the days it takes, with what is on them', async () => {
const answer = await ask({ start_date: '2026-10-01', end_date: '2026-10-03' }) as RangeRemoval
expect(repos.days).toHaveBeenCalledTimes(1)
expect(repos.reservations).toHaveBeenCalledTimes(1)
expect(repos.accommodations).toHaveBeenCalledTimes(1)
expect(answer.removedDays.map(r => r.day.id)).toEqual([4, 5])
expect(answer.content.places).toBe(2)
expect(answer.startMoved).toBe(false)
// Named the way the day list names them: the date, short.
expect(answer.dayLabels).toEqual(['Sun, Oct 4', 'Mon, Oct 5'])
})
it('FE-HOOK-RANGEGUARD-003: days that go empty ask nothing', async () => {
repos.days.mockResolvedValue({ days: fiveDays([1, 2]) })
expect(await ask({ start_date: '2026-10-01', end_date: '2026-10-03' })).toBeNull()
})
it('FE-HOOK-RANGEGUARD-004: when the days cannot be read the answer is unknown, not silence', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
repos.reservations.mockRejectedValue(new Error('500'))
expect(await ask({ start_date: '2026-10-01', end_date: '2026-10-03' })).toBe('unknown')
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
it('FE-HOOK-RANGEGUARD-005: a later start with the same end takes the last days and says the start moved', async () => {
const answer = await ask({ start_date: '2026-10-03', end_date: '2026-10-05' }) as RangeRemoval
expect(answer.removedDays.map(r => r.day.id)).toEqual([4, 5])
expect(answer.startMoved).toBe(true)
})
})
+87
View File
@@ -0,0 +1,87 @@
import { useCallback, useState } from 'react'
import { planDayGrid, resolveDayGridRange } from '@trek/shared'
import { useTranslation } from '../i18n'
import { dayRepo } from '../repo/dayRepo'
import { reservationRepo } from '../repo/reservationRepo'
import { accommodationRepo } from '../repo/accommodationRepo'
import { dayLabel } from '../utils/dayLabel'
import { hasVisibleContent, tripRangeImpact, type TripRangeImpact } from '../utils/tripRangeImpact'
import type { AssignmentsMap, DayNotesMap, Trip } from '../types'
/** Days a save would remove, with the names the day list gives them. */
export interface RangeRemoval extends TripRangeImpact {
dayLabels: string[]
}
/**
* The answer before a save: the days it removes with something on them, null
* when it removes nothing worth a question, or 'unknown' when the trip's days
* could not be read, so the dialog can still warn in general terms.
*/
export type RangeCheck = RangeRemoval | 'unknown' | null
/** The fields of a trip save that move the day grid. */
export interface RangePayload {
start_date?: string | null
end_date?: string | null
day_count?: number | null
}
/**
* Asks, before a trip dialog saves new dates, which days the save would remove
* and what is on them. It reads the trip's days, bookings and stays through the
* repos and lays the days out with planDayGrid, the rule the server rebuilds
* them by, so the warning names exactly the days the save takes. A save that
* does not rebuild the days is answered without reading anything.
*
* Used by the desktop trip dialog and the phone's trip sheet alike; both only
* render what comes back.
*/
export function useTripRangeGuard(): { checking: boolean; check: (trip: Pick<Trip, 'id' | 'start_date' | 'end_date'>, payload: RangePayload) => Promise<RangeCheck> } {
const { t, locale } = useTranslation()
const [checking, setChecking] = useState(false)
const check = useCallback(async (trip: Pick<Trip, 'id' | 'start_date' | 'end_date'>, payload: RangePayload): Promise<RangeCheck> => {
const range = resolveDayGridRange(trip, payload)
if (!range.regenerate) return null
setChecking(true)
try {
const [{ days }, { reservations }, { accommodations }] = await Promise.all([
dayRepo.list(trip.id),
reservationRepo.list(trip.id),
accommodationRepo.list(trip.id),
])
const plan = planDayGrid({
days: days.map(d => ({
id: d.id,
day_number: d.day_number ?? 0,
date: d.date,
hasPlanItems: (d.assignments?.length ?? 0) > 0 || (d.notes_items?.length ?? 0) > 0,
})),
stays: accommodations,
startDate: range.newStart,
endDate: range.newEnd,
dayCount: range.dayCount,
})
if (plan.removed.length === 0) return null
const assignments: AssignmentsMap = {}
const dayNotes: DayNotesMap = {}
for (const day of days) {
assignments[String(day.id)] = day.assignments ?? []
dayNotes[String(day.id)] = day.notes_items ?? []
}
const startMoved = !!trip.start_date && !!range.newStart && !!range.newEnd && range.newStart !== trip.start_date
const impact = tripRangeImpact(plan, days, { assignments, dayNotes, reservations, accommodations }, { startMoved })
if (!hasVisibleContent(impact)) return null
return { ...impact, dayLabels: impact.removedDays.map(({ day, index }) => dayLabel(day, index, t, locale)) }
} catch (err: unknown) {
console.error('Failed to read the days a new range removes:', err)
return 'unknown'
} finally {
setChecking(false)
}
}, [t, locale])
return { checking, check }
}
@@ -7,6 +7,7 @@ const PHONE: ImpactListSkin = {
row: 'flex items-start gap-[10px] px-[11px] py-[9px]',
divider: 'border-t border-[color:var(--m-rowbr)]',
chip: 'mt-[1px] flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full',
dayChip: 'rounded-full bg-[color:var(--m-ic)] px-[9px] py-[2px] text-[0.6875rem] font-semibold text-m-ink',
iconSize: 13,
text: 'text-[0.78125rem] font-semibold leading-snug',
hint: 'mt-[2px] text-[0.6875rem] leading-snug text-m-muted',
@@ -25,6 +26,6 @@ const PHONE: ImpactListSkin = {
}
/** The phone's day impact list: DayImpactList drawn in the mobile tokens. */
export default function MDayImpactList({ lines, label }: { lines: ImpactLine[]; label?: string }) {
return <DayImpactList lines={lines} label={label} skin={PHONE} />
export default function MDayImpactList({ lines, days, label }: { lines: ImpactLine[]; days?: string[]; label?: string }) {
return <DayImpactList lines={lines} days={days} label={label} skin={PHONE} />
}
@@ -15,6 +15,10 @@ import { MAX_TRIP_DAYS, tripSpanDays, type Trip, type TripCreateRequest } from '
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MListRow from '../../components/MListRow'
import MDayImpactList from '../../components/MDayImpactList'
import MConfirmSheet from '../settings/MConfirmSheet'
import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard'
import { dayChips, shrinkTripLines } from '../../../utils/dayImpactLines'
interface CoverSearchPhoto {
id: string
@@ -72,6 +76,11 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
const [searchResults, setSearchResults] = useState<CoverSearchPhoto[]>([])
const [searchError, setSearchError] = useState('')
const [searching, setSearching] = useState(false)
// A save whose new dates remove days with something on them, held until the
// traveller confirms. The phone has no shift step, so bookings keep their dates.
const [pendingRemoval, setPendingRemoval] = useState<{ payload: TripCreateRequest; removal: RangeRemoval | 'unknown' } | null>(null)
const rangeGuard = useTripRangeGuard()
const busy = isSaving || rangeGuard.checking
useEffect(() => {
if (!open) return
@@ -87,6 +96,7 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
setSearchResults([])
setSearchError('')
setError('')
setPendingRemoval(null)
}, [trip, open])
// The local file preview is a blob url; release it once a new cover replaces it
@@ -109,25 +119,40 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
setStartDate(value)
}
const handleSave = async () => {
/** The payload the form stands for, or null after putting the reason on screen. */
const validPayload = (): TripCreateRequest | null => {
setError('')
if (!title.trim()) { setError(t('dashboard.titleRequired')); return }
if (!title.trim()) { setError(t('dashboard.titleRequired')); return null }
if (startDate && endDate) {
const span = tripSpanDays(startDate, endDate)
if (span < 1) { setError(t('dashboard.endDateError')); return }
if (span < 1) { setError(t('dashboard.endDateError')); return null }
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 }
if (datesTouched && span > MAX_TRIP_DAYS) { setError(t('dashboard.tripTooLong', { days: MAX_TRIP_DAYS })); return null }
}
return {
title: title.trim(),
description: description.trim() || null,
start_date: startDate || null,
end_date: endDate || null,
currency,
...(!startDate && !endDate && !isEditing ? { day_count: 7 } : {}),
}
}
const handleSave = async () => {
const payload = validPayload()
if (!payload) return
// New dates that remove days with something on them ask first, the same
// question the desktop dialog asks, with the same list.
const removal = isEditing && trip ? await rangeGuard.check(trip, payload) : null
if (removal) { setPendingRemoval({ payload, removal }); return }
await save(payload)
}
const save = async (payload: TripCreateRequest) => {
setIsSaving(true)
try {
const result = await onSave({
title: title.trim(),
description: description.trim() || null,
start_date: startDate || null,
end_date: endDate || null,
currency,
...(!startDate && !endDate && !isEditing ? { day_count: 7 } : {}),
})
const result = await onSave(payload)
const created = result ? result.trip : undefined
if (pendingCoverFile && created?.id) {
try {
@@ -241,7 +266,10 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
const boxCls = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]'
const inputCls = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'
const removalList = pendingRemoval && pendingRemoval.removal !== 'unknown' ? pendingRemoval.removal : null
return (
<>
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
<div className="flex items-center gap-[11px] p-[16px_16px_0]">
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold">
@@ -433,12 +461,39 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
<button
type="button"
onClick={handleSave}
disabled={isSaving}
disabled={busy}
className="flex-1 rounded-full bg-m-act py-[10px] text-[0.8125rem] font-semibold text-m-actfg disabled:opacity-50"
>
{isSaving ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
</button>
</div>
</MSheet>
{/* After the sheet, so it opens on top of it. */}
<MConfirmSheet
open={pendingRemoval != null}
onClose={() => setPendingRemoval(null)}
title={t('dashboard.shrinkTitle')}
message={t(removalList ? 'dashboard.shrinkIntro' : 'dashboard.shrinkUnknown')}
confirmLabel={t('dashboard.shrinkConfirm')}
cancelLabel={t('common.cancel')}
danger
busy={isSaving}
onConfirm={() => {
if (!pendingRemoval) return
const { payload } = pendingRemoval
setPendingRemoval(null)
void save(payload)
}}
>
{removalList && (
<MDayImpactList
lines={shrinkTripLines(removalList, t)}
days={dayChips(removalList.dayLabels, t)}
label={t('dashboard.shrinkTitle')}
/>
)}
</MConfirmSheet>
</>
)
}
+24 -1
View File
@@ -1,4 +1,4 @@
// FE-TSTORE-001 to FE-TSTORE-021 (trip-scoped root store: load, hydrate, refresh, mutate)
// FE-TSTORE-001 to FE-TSTORE-022 (trip-scoped root store: load, hydrate, refresh, mutate)
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
@@ -411,6 +411,29 @@ describe('tripStore', () => {
await expect(useTripStore.getState().updateTrip(1, { title: 'New' })).rejects.toThrow('Not the owner');
expect(useTripStore.getState().trip?.title).toBe('Old');
});
it('FE-TSTORE-022: new dates nudge the planner to reload its stays, a rename does not', async () => {
const refreshes = vi.fn();
window.addEventListener('accommodations:refresh', refreshes);
try {
seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Week', start_date: '2025-06-01', end_date: '2025-06-07' }) });
let answer = buildTrip({ id: 1, title: 'Renamed', start_date: '2025-06-01', end_date: '2025-06-07' });
server.use(
http.put('/api/trips/1', () => HttpResponse.json({ trip: answer })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
);
await useTripStore.getState().updateTrip(1, { title: 'Renamed' });
expect(refreshes).not.toHaveBeenCalled();
// A stay on a day the shorter range removed is gone on the server.
answer = buildTrip({ id: 1, title: 'Renamed', start_date: '2025-06-01', end_date: '2025-06-05' });
await useTripStore.getState().updateTrip(1, { end_date: '2025-06-05' });
expect(refreshes).toHaveBeenCalledTimes(1);
} finally {
window.removeEventListener('accommodations:refresh', refreshes);
}
});
});
describe('addTag', () => {
+7
View File
@@ -215,8 +215,15 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
updateTrip: async (tripId: number | string, data: Partial<Trip> & { date_shift_mode?: 'keep_bookings' | 'shift_all' }) => {
try {
const before = get().trip
const result = await tripsApi.update(tripId, data)
set({ trip: result.trip })
// New dates rebuild the day rows, and a stay on a day that went is gone
// with it. Stays live in the planner, not here, so every caller of this
// action (the phone's trip dialog too) nudges the planner to reload them.
if (before?.start_date !== result.trip?.start_date || before?.end_date !== result.trip?.end_date) {
window.dispatchEvent(new CustomEvent('accommodations:refresh'))
}
const daysData = await dayRepo.list(tripId)
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
+49 -1
View File
@@ -1,4 +1,4 @@
// FE-UTIL-DAYDEL-001 to FE-UTIL-DAYDEL-008
// FE-UTIL-DAYDEL-001 to FE-UTIL-DAYDEL-012
import { describe, it, expect } from 'vitest'
import { buildAssignment, buildDay, buildDayNote, buildPlace, buildReservation } from '../../tests/helpers/factories'
import { contentOnDays, dayDeleteImpact, deleteDayBlockedReason, type DayContentData } from './dayDeleteImpact'
@@ -122,3 +122,51 @@ describe('dayDeleteImpact', () => {
expect(deleteDayBlockedReason(3, false, t)).toBeNull()
})
})
describe('contentOnDays over several days (the shrink warning)', () => {
it('FE-UTIL-DAYDEL-009: counts across all the days at once', () => {
const data = {
...empty(),
assignments: {
'7': [buildAssignment({ day_id: 7, place: buildPlace({ id: 50 }) })],
'8': [buildAssignment({ day_id: 8, place: buildPlace({ id: 51 }) })],
},
dayNotes: { '7': [buildDayNote({ day_id: 7 })], '8': [buildDayNote({ day_id: 8 }), buildDayNote({ day_id: 8 })] },
reservations: [buildReservation({ id: 1, day_id: 7 }), buildReservation({ id: 2, day_id: 8 }), buildReservation({ id: 3, day_id: 9 })],
}
const days = [buildDay({ id: 7, title: 'Harbour day' }), buildDay({ id: 8, notes: 'Ferry at nine' })]
expect(contentOnDays(days, data)).toEqual({ places: 2, notes: 3, texts: 2, bookings: 2, stays: [] })
})
it('FE-UTIL-DAYDEL-010: a stay from one of the days to another counts once', () => {
const data = { ...empty(), accommodations: [stay({ id: 9, start_day_id: 7, end_day_id: 8, place_name: 'Harbour Hotel' })] }
expect(contentOnDays([buildDay({ id: 7 }), buildDay({ id: 8 })], data).stays).toEqual([
{ id: 9, name: 'Harbour Hotel', booking: null },
])
})
it('FE-UTIL-DAYDEL-011: the hotel booking of such a stay is the stay, not another booking', () => {
const data = {
...empty(),
accommodations: [stay({ id: 9, start_day_id: 7, end_day_id: 8 })],
reservations: [buildReservation({ id: 4, day_id: 7, end_day_id: 8, type: 'hotel', title: 'Harbour, 1 night', accommodation_id: 9 })],
}
expect(contentOnDays([buildDay({ id: 7 }), buildDay({ id: 8 })], data)).toMatchObject({
bookings: 0,
stays: [{ id: 9, booking: 'Harbour, 1 night' }],
})
})
it('FE-UTIL-DAYDEL-012: the same place on two days is one place, and the stops a stay wrote are none', () => {
const museum = buildPlace({ id: 50 })
const hotel = buildPlace({ id: 52 })
const data = {
...empty(),
assignments: {
'7': [buildAssignment({ day_id: 7, place: museum }), buildAssignment({ day_id: 7, place: hotel, accommodation_id: 9 })],
'8': [buildAssignment({ day_id: 8, place: museum }), buildAssignment({ day_id: 8, place: hotel, accommodation_id: 9 })],
},
}
expect(contentOnDays([buildDay({ id: 7 }), buildDay({ id: 8 })], data).places).toBe(1)
})
})
+49 -3
View File
@@ -1,8 +1,9 @@
// FE-UTIL-DAYLINES-001 to FE-UTIL-DAYLINES-004
// FE-UTIL-DAYLINES-001 to FE-UTIL-DAYLINES-008
import { describe, it, expect } from 'vitest'
import { BedDouble, CalendarCheck } from 'lucide-react'
import { deleteDayLines, impactLines } from './dayImpactLines'
import { BedDouble, CalendarCheck, CalendarClock } from 'lucide-react'
import { dayChips, deleteDayLines, impactLines, shrinkTripLines } from './dayImpactLines'
import type { DayDeleteImpact } from './dayDeleteImpact'
import type { TripRangeImpact } from './tripRangeImpact'
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
@@ -54,3 +55,48 @@ describe('deleteDayLines', () => {
expect(lines).toEqual([{ key: 'empty', icon: CalendarCheck, tone: 'muted', text: 'dayplan.deleteDayEmpty' }])
})
})
describe('shrinkTripLines', () => {
const shrink = (content: Partial<TripRangeImpact['content']>, startMoved = false): TripRangeImpact => ({
removedDays: [],
startMoved,
content: { places: 0, notes: 0, texts: 0, bookings: 0, stays: [], ...content },
})
it('FE-UTIL-DAYLINES-005: the same rows, with the shortening hints: a removed stay leaves its booking behind', () => {
const lines = shrinkTripLines(shrink({ places: 2, stays: [{ id: 9, name: 'Harbour Hotel', booking: 'HH' }, { id: 10, name: 'Alma', booking: null }] }), t)
expect(lines.map(l => [l.key, l.tone, l.hint])).toEqual([
['stay-9', 'danger', 'dashboard.shrinkStayBookedHint(booking=HH)'],
['stay-10', 'danger', 'dashboard.shrinkStayHint'],
['places', 'neutral', 'dayplan.impactPlacesHint'],
])
})
it('FE-UTIL-DAYLINES-006: the booking hint follows the shift mode', () => {
const hint = (mode?: 'keep_bookings' | 'shift_all') => shrinkTripLines(shrink({ bookings: 2 }), t, mode)[0].hint
expect(hint()).toBe('dashboard.shrinkBookingsHint')
expect(hint('keep_bookings')).toBe('dashboard.shrinkBookingsHint')
expect(hint('shift_all')).toBe('dashboard.shrinkBookingsShiftHint')
// Deleting a day keeps its own hint whatever mode is passed along.
expect(impactLines(shrink({ bookings: 1 }).content, t, 'deleteDay', { shiftMode: 'shift_all' })[0].hint).toBe('dayplan.deleteDayBookingsHint')
})
it('FE-UTIL-DAYLINES-007: a moved start that still takes the last days gets a last row that says so', () => {
const lines = shrinkTripLines(shrink({ notes: 1 }, true), t)
expect(lines[lines.length - 1]).toEqual({
key: 'lastDays',
icon: CalendarClock,
tone: 'warning',
text: 'dashboard.shrinkLastDays',
hint: 'dashboard.shrinkLastDaysHint',
})
expect(shrinkTripLines(shrink({ notes: 1 }), t).map(l => l.key)).toEqual(['notes'])
})
it('FE-UTIL-DAYLINES-008: day chips name six days and sum up the rest', () => {
const labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', 'Day 8']
expect(dayChips(labels.slice(0, 6), t)).toEqual(labels.slice(0, 6))
expect(dayChips(labels, t)).toEqual([...labels.slice(0, 6), 'dashboard.shrinkMoreDays(count=2)'])
expect(dayChips(labels, t, 3)).toEqual(['Mon', 'Tue', 'Wed', 'dashboard.shrinkMoreDays(count=5)'])
})
})
+53 -6
View File
@@ -1,6 +1,7 @@
import { BedDouble, CalendarCheck, CalendarClock, CalendarMinus, MapPin, StickyNote, Ticket, Type } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { DayContent, DayDeleteImpact } from './dayDeleteImpact'
import type { TripRangeImpact } from './tripRangeImpact'
type Translate = (key: string, params?: Record<string, string | number>) => string
@@ -21,11 +22,15 @@ export interface ImpactLine {
}
/**
* The question the list answers. Deleting a day cancels a stay cleanly and lets
* go of its bookings; other ways of losing days treat both differently, so the
* hints are looked up per variant while the rows themselves stay the same.
* The question the list answers. Deleting a day cancels a stay cleanly, with
* its booking and expense. Shortening a trip removes the stay through the day
* rows instead and leaves its booking behind. So the hints are looked up per
* variant while the rows themselves stay the same.
*/
export type ImpactVariant = 'deleteDay'
export type ImpactVariant = 'deleteDay' | 'shrinkTrip'
/** How bookings follow new dates, as the trip dialog lets the traveller choose. */
export type ShiftMode = 'keep_bookings' | 'shift_all'
const HINTS: Record<ImpactVariant, { bookings: string; stay: string; stayBooked: string }> = {
deleteDay: {
@@ -33,6 +38,11 @@ const HINTS: Record<ImpactVariant, { bookings: string; stay: string; stayBooked:
stay: 'dayplan.deleteDayStayHint',
stayBooked: 'dayplan.deleteDayStayBookedHint',
},
shrinkTrip: {
bookings: 'dashboard.shrinkBookingsHint',
stay: 'dashboard.shrinkStayHint',
stayBooked: 'dashboard.shrinkStayBookedHint',
},
}
/**
@@ -40,8 +50,17 @@ const HINTS: Record<ImpactVariant, { bookings: string; stay: string; stayBooked:
* money, then places, notes, day texts and bookings. Kinds with nothing in them
* are left out.
*/
export function impactLines(content: DayContent, t: Translate, variant: ImpactVariant): ImpactLine[] {
const hints = HINTS[variant]
export function impactLines(
content: DayContent,
t: Translate,
variant: ImpactVariant,
options: { shiftMode?: ShiftMode } = {},
): ImpactLine[] {
// With "shift everything" a booking stays glued to its day row, so one on a
// removed day keeps no day at all; the default puts it back by its date.
const hints = variant === 'shrinkTrip' && options.shiftMode === 'shift_all'
? { ...HINTS.shrinkTrip, bookings: 'dashboard.shrinkBookingsShiftHint' }
: HINTS[variant]
const lines: ImpactLine[] = content.stays.map(stay => ({
key: `stay-${stay.id}`,
icon: BedDouble,
@@ -95,3 +114,31 @@ export function deleteDayLines(impact: DayDeleteImpact, t: Translate, formatDate
}
return lines
}
/** Most day chips the list names before it sums up the rest. */
export const MAX_DAY_CHIPS = 6
/** The removed days as chip labels: the first few by name, the rest as "+n more". */
export function dayChips(labels: string[], t: Translate, max = MAX_DAY_CHIPS): string[] {
if (labels.length <= max) return labels
return [...labels.slice(0, max), t('dashboard.shrinkMoreDays', { count: labels.length - max })]
}
/**
* The full list for the warning before a trip is shortened: what sits on the
* days that go, with the booking hint following the chosen shift mode, and a
* last row when a moved start still takes the last days.
*/
export function shrinkTripLines(impact: TripRangeImpact, t: Translate, shiftMode: ShiftMode = 'keep_bookings'): ImpactLine[] {
const lines = impactLines(impact.content, t, 'shrinkTrip', { shiftMode })
if (impact.startMoved) {
lines.push({
key: 'lastDays',
icon: CalendarClock,
tone: 'warning',
text: t('dashboard.shrinkLastDays'),
hint: t('dashboard.shrinkLastDaysHint'),
})
}
return lines
}
+111
View File
@@ -0,0 +1,111 @@
// FE-UTIL-RANGEDEL-001 to FE-UTIL-RANGEDEL-008
import { describe, it, expect } from 'vitest'
import { planDayGrid } from '@trek/shared'
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../tests/helpers/factories'
import { hasVisibleContent, tripRangeImpact } from './tripRangeImpact'
import type { DayContentData } from './dayDeleteImpact'
import type { Accommodation, Day } from '../types'
/**
* The trip of the plan: 1 to 10 October, a place on every day (ids 1 to 10),
* then two days without a date: 11 is empty, 12 has a place on it.
*/
function tenDays(): Day[] {
return [
...Array.from({ length: 10 }, (_, i) =>
buildDay({ id: i + 1, day_number: i + 1, date: `2026-10-${String(i + 1).padStart(2, '0')}` })),
buildDay({ id: 11, day_number: 11, date: null }),
buildDay({ id: 12, day_number: 12, date: null }),
]
}
function contentOn(days: Day[]): DayContentData {
const assignments: DayContentData['assignments'] = {}
for (const day of days) {
if (day.id !== 11) assignments[String(day.id)] = [buildAssignment({ day_id: day.id, place: buildPlace({ id: 100 + day.id }) })]
}
return { assignments, dayNotes: {}, reservations: [], accommodations: [] }
}
function impactFor(start: string | null, end: string | null, options = { startMoved: false }, days = tenDays(), data = contentOn(days)) {
const plan = planDayGrid({
days: days.map(d => ({
id: d.id,
day_number: d.day_number ?? 0,
date: d.date,
hasPlanItems: (data.assignments[String(d.id)]?.length ?? 0) > 0,
})),
stays: data.accommodations,
startDate: start,
endDate: end,
})
return tripRangeImpact(plan, days, data, options)
}
const ids = (impact: ReturnType<typeof impactFor>) => impact.removedDays.map(r => r.day.id)
describe('tripRangeImpact', () => {
it('FE-UTIL-RANGEDEL-001: an earlier end takes the last days with their places, and the empty spare day', () => {
const impact = impactFor('2026-10-01', '2026-10-08')
expect(ids(impact)).toEqual([9, 10, 11])
expect(impact.content.places).toBe(2)
expect(impact.startMoved).toBe(false)
expect(hasVisibleContent(impact)).toBe(true)
})
it('FE-UTIL-RANGEDEL-002: a later start with the same end still takes the last days, and says so', () => {
const impact = impactFor('2026-10-03', '2026-10-10', { startMoved: true })
expect(ids(impact)).toEqual([9, 10, 11])
expect(impact.startMoved).toBe(true)
})
it('FE-UTIL-RANGEDEL-003: moving the whole range drops only the empty spare day, which is not worth a word', () => {
const impact = impactFor('2026-10-05', '2026-10-14', { startMoved: true })
expect(ids(impact)).toEqual([11])
// Nothing dated went, so the "last days" row would be wrong here.
expect(impact.startMoved).toBe(false)
expect(hasVisibleContent(impact)).toBe(false)
})
it('FE-UTIL-RANGEDEL-004: a later end removes nothing', () => {
const impact = impactFor('2026-10-01', '2026-10-11')
expect(impact.removedDays).toEqual([])
expect(hasVisibleContent(impact)).toBe(false)
})
it('FE-UTIL-RANGEDEL-005: clearing the dates keeps every day', () => {
const impact = impactFor(null, null)
expect(impact.removedDays).toEqual([])
expect(impact.content).toEqual({ places: 0, notes: 0, texts: 0, bookings: 0, stays: [] })
})
it('FE-UTIL-RANGEDEL-006: a spare day with only a title goes, and its title is what the warning names', () => {
const days = tenDays().map(d => (d.id === 11 ? { ...d, title: 'Buffer day' } : d))
const impact = impactFor('2026-10-05', '2026-10-14', { startMoved: false }, days)
expect(ids(impact)).toEqual([11])
expect(impact.content.texts).toBe(1)
expect(hasVisibleContent(impact)).toBe(true)
})
it('FE-UTIL-RANGEDEL-007: a stay on a removed day goes as a whole, with its booking, and a booking there counts', () => {
const days = tenDays()
const data = contentOn(days)
data.accommodations = [
{ id: 5, trip_id: 1, start_day_id: 7, end_day_id: 9, place_name: 'Harbour Hotel' } as Accommodation,
{ id: 6, trip_id: 1, start_day_id: 2, end_day_id: 4, place_name: 'Pension Alma' } as Accommodation,
]
data.reservations = [
buildReservation({ id: 40, day_id: 7, type: 'hotel', title: 'Harbour, 2 nights', accommodation_id: 5 }),
buildReservation({ id: 41, day_id: 10, title: 'Ferry' }),
]
const impact = impactFor('2026-10-01', '2026-10-08', { startMoved: false }, days, data)
expect(impact.content.stays).toEqual([{ id: 5, name: 'Harbour Hotel', booking: 'Harbour, 2 nights' }])
expect(impact.content.bookings).toBe(1)
})
it('FE-UTIL-RANGEDEL-008: the removed days come in list order, each with its place in the list', () => {
const days = [...tenDays()].reverse()
const impact = impactFor('2026-10-01', '2026-10-09', { startMoved: false }, days)
expect(impact.removedDays.map(r => [r.day.id, r.index])).toEqual([[10, 9], [11, 10]])
})
})
+57
View File
@@ -0,0 +1,57 @@
import type { DayGridPlan } from '@trek/shared'
import type { Day } from '../types'
import { contentOnDays, type DayContent, type DayContentData } from './dayDeleteImpact'
/** A day a save would remove, with its place in the day list (for "Day n"). */
export interface RemovedDay {
day: Day
index: number
}
/** What a new range takes away: the days, in list order, and what sits on them. */
export interface TripRangeImpact {
removedDays: RemovedDay[]
/**
* The start moved while dated days fall off the end. Plans follow their
* position, so it is the last days that go, not the first: worth saying,
* because it is the opposite of what moving the start suggests.
*/
startMoved: boolean
content: DayContent
}
const byNumber = (a: Day, b: Day): number => (a.day_number ?? 0) - (b.day_number ?? 0)
/**
* Read a planDayGrid plan as the trip dialog shows it: which of the current
* days go and everything on them, counted the same way the delete day
* question counts a single day.
*/
export function tripRangeImpact(
plan: DayGridPlan,
days: Day[],
data: DayContentData,
options: { startMoved: boolean },
): TripRangeImpact {
const gone = new Set(plan.removed.map(r => r.id))
const removedDays = [...days]
.sort(byNumber)
.map((day, index) => ({ day, index }))
.filter(({ day }) => gone.has(day.id))
const dropsDatedDays = plan.removed.some(r => r.reason === 'overflow')
return {
removedDays,
startMoved: options.startMoved && dropsDatedDays,
content: contentOnDays(removedDays.map(r => r.day), data),
}
}
/**
* Whether the days that go hold anything a traveller would miss. Empty days
* come and go with every change of dates; asking about them would teach people
* to click the question away.
*/
export function hasVisibleContent(impact: TripRangeImpact): boolean {
const { places, notes, texts, bookings, stays } = impact.content
return places + notes + texts + bookings + stays.length > 0
}
@@ -6,7 +6,9 @@ import { tripsApi } from '../../../../src/api/client';
import { useAuthStore } from '../../../../src/store/authStore';
import { usePermissionsStore } from '../../../../src/store/permissionsStore';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { buildUser } from '../../../helpers/factories';
import { buildAssignment, buildDay, buildPlace, buildUser } from '../../../helpers/factories';
import { http, HttpResponse } from 'msw';
import { server } from '../../../helpers/msw/server';
import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel';
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared';
@@ -559,4 +561,96 @@ describe('MNewTripSheet', () => {
expect(onClose).toHaveBeenCalledTimes(2);
expect(onSave).not.toHaveBeenCalled();
});
// ── Shortening an existing trip: the same question as on the desktop ─────
/** The trip's days as the server has them: 1 to 8 May, a place on the last two. */
function seedDays(withContent = true) {
const days = Array.from({ length: 8 }, (_, i) => buildDay({
id: 500 + i,
trip_id: 42,
day_number: i + 1,
date: `2026-05-0${i + 1}`,
assignments: withContent && i >= 6 ? [buildAssignment({ day_id: 500 + i, place: buildPlace({ id: 600 + i }) })] : [],
}));
server.use(
http.get('/api/trips/:id/days', () => HttpResponse.json({ days })),
http.get('/api/trips/:id/reservations', () => HttpResponse.json({ reservations: [] })),
http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ accommodations: [] })),
);
}
async function shortenToSixDays(onSave: (data: TripCreateRequest) => Promise<void>, onClose = () => {}) {
render(<MNewTripSheet open trip={buildDashTrip()} onClose={onClose} onSave={onSave} />);
fireEvent.change(screen.getByLabelText('End Date'), { target: { value: '2026-05-06' } });
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
}
it('FE-MOB-NTSH-037: an earlier end opens a danger question that names the days and what is on them', async () => {
seedDays();
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
await shortenToSixDays(onSave);
const sheet = await screen.findByRole('dialog', { name: 'Remove days?' });
expect(sheet).toHaveTextContent('Saving the new dates removes these days:');
const list = screen.getByRole('list', { name: 'Remove days?' });
expect(list).toHaveTextContent('Thu, May 7');
expect(list).toHaveTextContent('Fri, May 8');
expect(list).toHaveTextContent('Planned places: 2');
// The phone has no shift step, so the booking hint is the default one.
expect(screen.getByRole('button', { name: 'Remove days and save' })).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('FE-MOB-NTSH-038: cancelling the question saves nothing and keeps the edit sheet', async () => {
seedDays();
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
const onClose = vi.fn();
await shortenToSixDays(onSave, onClose);
const sheet = await screen.findByRole('dialog', { name: 'Remove days?' });
const cancel = Array.from(sheet.querySelectorAll('button')).find(b => b.textContent === 'Cancel')!;
fireEvent.click(cancel);
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove days?' })).not.toBeInTheDocument());
expect(onSave).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByLabelText('End Date')).toHaveValue('2026-05-06');
});
it('FE-MOB-NTSH-039: confirming saves the new dates and closes the sheet', async () => {
seedDays();
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
const onClose = vi.fn();
await shortenToSixDays(onSave, onClose);
fireEvent.click(await screen.findByRole('button', { name: 'Remove days and save' }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ start_date: '2026-05-01', end_date: '2026-05-06' }));
expect(onSave.mock.calls[0][0]).not.toHaveProperty('date_shift_mode');
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('FE-MOB-NTSH-040: when only empty days go there is no question, the save goes through', async () => {
seedDays(false);
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
await shortenToSixDays(onSave);
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ end_date: '2026-05-06' })));
expect(screen.queryByRole('dialog', { name: 'Remove days?' })).not.toBeInTheDocument();
});
it('FE-MOB-NTSH-041: when the days cannot be read it still asks, in general terms', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
server.use(http.get('/api/trips/:id/days', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
await shortenToSixDays(onSave);
const sheet = await screen.findByRole('dialog', { name: 'Remove days?' });
expect(sheet).toHaveTextContent('The days of this trip could not be checked.');
expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
spy.mockRestore();
});
});
+4 -3
View File
@@ -129,7 +129,7 @@ export class TripsMcp {
@Tool({
name: 'update_trip',
description: 'Update an existing trip\'s details.',
description: 'Update an existing trip\'s details. Shortening a dated trip deletes its last days by position, with their planned places, notes and any stay that checks in or out on them; day plans move with the dates, so a later start with the same end also takes the last days. When a change removed days, the result lists them in removed_days (id, day_number and date as they stood before; reason overflow for a day past the new range, spare for an empty one).',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
@@ -184,9 +184,10 @@ export class TripsMcp {
// update() re-anchors the budget before the trip row moves off the old
// currency (#1543) and then runs the legacy updateTrip core.
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');
const { updatedTrip, removedDays } = 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 });
// Only when days went, so the answer to a rename or a longer trip stays as it was.
return ok({ trip: updatedTrip, ...(removedDays.length > 0 ? { removed_days: removedDays } : {}) });
} catch (err) {
if (err instanceof ValidationError) return errorResult(err.message);
throw err;
+63 -123
View File
@@ -1,7 +1,17 @@
import { Injectable } from '@nestjs/common';
import path from 'path';
import { DatabaseService } from '../database/database.service';
import { MAX_TRIP_DAYS, tripSpanDays, type ActiveTrip, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import {
MAX_TRIP_DAYS,
planDayGrid,
resolveDayGridRange,
tripSpanDays,
type ActiveTrip,
type DayGridPlan,
type DayGridRemoval,
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';
@@ -15,8 +25,6 @@ import { SettingsService } from '../settings/settings.service';
import { NotFoundError, ValidationError } from '../common/domain-errors';
import { TRIP_SELECT } from './trip-select';
export const MS_PER_DAY = 86400000;
/**
* 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
@@ -68,6 +76,8 @@ export interface UpdateTripResult {
newTitle: string;
newReminder: number;
oldReminder: number;
/** The day rows a changed range took away, as they stood before; empty when none went. */
removedDays: DayGridRemoval[];
}
export interface DeleteTripInfo {
@@ -173,121 +183,53 @@ export class TripsService {
// ── Day generation ────────────────────────────────────────────────────────
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 = ?');
/**
* Lay the trip's day rows out for a range, by the shared planDayGrid rule the
* trip dialog warns by: the plan says which row takes which position and date
* and which rows go, and this carries it out. The two-phase numbering keeps
* UNIQUE(trip_id, day_number) quiet while rows swap places. A removed day
* takes its assignments, notes and every stay checking in or out on it along
* through the foreign keys, as it always has (#909). Returns the plan it ran.
*/
generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, dayCount?: number): DayGridPlan {
return this.db.transaction(() => {
const existing = this.db.prepare(`
SELECT d.id, d.day_number, d.date,
EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = d.id)
OR EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = d.id) AS has_plan_items
FROM days d WHERE d.trip_id = ?
`).all(tripId) as { id: number; day_number: number; date: string | null; has_plan_items: number }[];
const stays = this.db.prepare(`
SELECT dac.start_day_id, dac.end_day_id FROM day_accommodations dac
WHERE dac.start_day_id IN (SELECT id FROM days WHERE trip_id = ?)
OR dac.end_day_id IN (SELECT id FROM days WHERE trip_id = ?)
`).all(tripId, tripId) as { start_day_id: number; end_day_id: number }[];
// Helper: two-phase renumber to avoid UNIQUE(trip_id, day_number) collisions
function renumber(days: { id: number }[]) {
days.forEach((d, i) => setDayNumber.run(-(i + 1), d.id));
days.forEach((d, i) => setDayNumber.run(i + 1, d.id));
}
const plan = planDayGrid({
days: existing.map(d => ({ id: d.id, day_number: d.day_number, date: d.date, hasPlanItems: !!d.has_plan_items })),
stays,
startDate,
endDate,
dayCount,
});
if (!startDate || !endDate) {
// Nullify all dated days instead of deleting them — preserves assignments/notes/accommodations
const withDates = existing.filter(d => d.date);
if (withDates.length > 0) {
const nullify = this.db.prepare('UPDATE days SET date = NULL WHERE id = ?');
for (const d of withDates) nullify.run(d.id);
}
// Now all days are dateless — adjust count toward dayCount target
const allDays = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
const targetCount = Math.min(Math.max(dayCount ?? (allDays.length || 7), 1), MAX_TRIP_DAYS);
const needed = targetCount - allDays.length;
if (needed > 0) {
const insert = this.db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)');
for (let i = 0; i < needed; i++) insert.run(tripId, allDays.length + i + 1);
} else if (needed < 0) {
// Only trim trailing empty days to avoid destroying content
const candidates = this.db.prepare(
`SELECT d.id FROM days d
WHERE d.trip_id = ?
AND NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = d.id)
AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = d.id)
AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = d.id OR dac.end_day_id = d.id)
ORDER BY d.day_number DESC
LIMIT ?`
).all(tripId, -needed) as { id: number }[];
const del = this.db.prepare('DELETE FROM days WHERE id = ?');
for (const d of candidates) del.run(d.id);
}
const remaining = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
renumber(remaining);
return;
}
const dateBefore = new Map(existing.map(d => [d.id, d.date]));
const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
const assignDay = this.db.prepare('UPDATE days SET date = ?, day_number = ? WHERE id = ?');
const insert = this.db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
const del = this.db.prepare('DELETE FROM days WHERE id = ?');
const [sy, sm, sd] = startDate.split('-').map(Number);
const startMs = Date.UTC(sy, sm - 1, sd);
const numDays = tripSpanDays(startDate, endDate);
const targetDates: string[] = [];
for (let i = 0; i < numDays; i++) {
const d = new Date(startMs + i * MS_PER_DAY);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
targetDates.push(`${yyyy}-${mm}-${dd}`);
}
// Split into dated (sorted by day_number = position) and dateless (spare pool)
const dated = existing.filter(d => d.date).sort((a, b) => a.day_number - b.day_number);
const dateless = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number);
// Phase 1: stamp all existing days with negative day_numbers to free up slots
const allExisting = [...dated, ...dateless];
allExisting.forEach((d, i) => setDayNumber.run(-(i + 1), d.id));
const assignDay = this.db.prepare('UPDATE days SET date = ?, day_number = ? WHERE id = ?');
const insert = this.db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
let datelessIdx = 0;
for (let i = 0; i < targetDates.length; i++) {
const date = targetDates[i];
if (i < dated.length) {
// Positional remap: existing dated day i gets new date — keeps all children
assignDay.run(date, i + 1, dated[i].id);
} else if (datelessIdx < dateless.length) {
// Reuse a dateless day — keeps its assignments, notes, etc.
assignDay.run(date, i + 1, dateless[datelessIdx].id);
datelessIdx++;
} else {
insert.run(tripId, i + 1, date);
}
}
// Overflow dated days (trip shrunk): delete them (issue #909).
// Cascade removes their assignments, notes, and accommodations.
const del = this.db.prepare('DELETE FROM days WHERE id = ?');
for (let i = targetDates.length; i < dated.length; i++) {
del.run(dated[i].id);
}
// Any remaining unused dateless days: drop the empty placeholders so day_count
// reflects the dated range, but keep ones that still hold content (assignments,
// notes, accommodations) — mirrors the dateless-path trimming above (#1083).
// Base must be max(targetDates.length, dated.length) to avoid colliding with
// positives already assigned by the main loop or the overflow loop above.
const isEmptyDay = this.db.prepare(
`SELECT NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = @id)
AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = @id)
AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = @id OR dac.end_day_id = @id) AS empty`
);
const maxAssigned = Math.max(targetDates.length, dated.length);
let keptDateless = 0;
for (let i = datelessIdx; i < dateless.length; i++) {
const empty = (isEmptyDay.get({ id: dateless[i].id }) as { empty: number }).empty;
if (empty) {
del.run(dateless[i].id);
} else {
setDayNumber.run(maxAssigned + keptDateless + 1, dateless[i].id);
keptDateless++;
}
}
// Final renumber to compact and eliminate any gaps/negatives
const remaining = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
renumber(remaining);
existing.forEach((d, i) => setDayNumber.run(-(i + 1), d.id));
plan.rows.forEach((row, i) => {
if (row.id === null) insert.run(tripId, i + 1, row.date);
// A row that had no date and gets none is only renumbered, so an odd
// stored value is left as it was, the way the rebuild always treated it.
else if (row.date === null && !dateBefore.get(row.id)) setDayNumber.run(i + 1, row.id);
else assignDay.run(row.date, i + 1, row.id);
});
for (const gone of plan.removed) del.run(gone.id);
return plan;
})();
}
// ── Trip CRUD ─────────────────────────────────────────────────────────────
@@ -407,6 +349,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);
let removedDays: DayGridRemoval[] = [];
if (regenerate) {
this.db.transaction(() => {
// Accommodations have no absolute date columns, so their pre-change dates must be
@@ -415,7 +358,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, dayCount);
removedDays = this.generateDays(tripId, newStart || null, newEnd || null, dayCount).removed;
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.
@@ -449,7 +392,7 @@ export class TripsService {
const updatedTrip = this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
return { updatedTrip, changes, isAdminEdit, ownerEmail, newTitle, newReminder, oldReminder };
return { updatedTrip, changes, isAdminEdit, ownerEmail, newTitle, newReminder, oldReminder, removedDays };
}
/**
@@ -463,12 +406,9 @@ export class TripsService {
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 };
const range = resolveDayGridRange(trip, data);
if (range.regenerate && range.newStart && range.newEnd) assertTripSpan(range.newStart, range.newEnd);
return range;
}
async update(tripId: string | number, userId: number, body: UpdateTripData, role: string) {
+12
View File
@@ -239,6 +239,18 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
expect(db.prepare('SELECT end_date FROM trips WHERE id = ?').get(week.body.trip.id)).toEqual({ end_date: '2026-07-07' });
});
it('200 update with an earlier end drops the last days, and the answer stays { trip }', async () => {
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 kept = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number LIMIT 5').all(week.body.trip.id);
const res = await request(server).put(`/api/trips/${week.body.trip.id}`).set('Cookie', sessionCookie(1))
.send({ end_date: '2026-07-05' });
expect(res.status).toBe(200);
expect(Object.keys(res.body)).toEqual(['trip']);
expect(res.body.trip).toMatchObject({ start_date: '2026-07-01', end_date: '2026-07-05', day_count: 5 });
expect(db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(week.body.trip.id)).toEqual(kept);
});
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);
+27
View File
@@ -501,6 +501,33 @@ describe('Update trip', () => {
const all = testDb.prepare('SELECT * FROM day_assignments WHERE id IN (?, ?)').all(a4.id, a5.id) as { id: number }[];
expect(all).toHaveLength(0);
});
it('TRIP-028: An earlier end takes the last day with its stay, while the hotel booking of that stay remains', async () => {
// Pins what the trip dialog warns about before such a save: the stay goes as
// a whole, even with nights still inside the trip, and its booking does not.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-06' });
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
const place = createPlace(testDb, trip.id, { name: 'Harbour Hotel' });
const stay = createDayAccommodation(testDb, trip.id, place.id, days[3].id, days[5].id);
const booking = createReservation(testDb, trip.id, { title: 'Harbour Hotel booking', type: 'hotel', day_id: days[3].id });
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(stay.id, booking.id);
const sightseeing = createDayAssignment(testDb, days[5].id, place.id);
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ start_date: '2026-09-01', end_date: '2026-09-05' });
expect(res.status).toBe(200);
const daysAfter = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string }[];
expect(daysAfter.map(d => d.id)).toEqual(days.slice(0, 5).map(d => d.id));
expect(daysAfter.at(-1)!.date).toBe('2026-09-05');
expect(testDb.prepare('SELECT 1 FROM day_accommodations WHERE id = ?').get(stay.id)).toBeUndefined();
expect(testDb.prepare('SELECT 1 FROM day_assignments WHERE id = ?').get(sightseeing.id)).toBeUndefined();
expect(testDb.prepare('SELECT title FROM reservations WHERE id = ?').get(booking.id)).toEqual({ title: 'Harbour Hotel booking' });
expect(testDb.prepare('SELECT 1 FROM places WHERE id = ?').get(place.id)).toBeDefined();
});
});
// ─────────────────────────────────────────────────────────────────────────────
+27
View File
@@ -421,6 +421,33 @@ describe('Tool: update_trip', () => {
});
});
it('says in its description what shortening a dated trip takes, and where the result lists it', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const tool = (await h.client.listTools()).tools.find(t => t.name === 'update_trip');
expect(tool?.description).toContain('Shortening a dated trip deletes its last days by position');
expect(tool?.description).toContain('removed_days');
});
});
it('lists the days an earlier end removed, as they stood before, and none for a rename', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-05' });
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
await withHarness(user.id, async (h) => {
type Answer = { trip: { end_date: string; title: string }; removed_days?: unknown[] };
const shortened = parseToolResult(await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, end_date: '2026-07-03' } })) as Answer;
expect(shortened.trip.end_date).toBe('2026-07-03');
expect(shortened.removed_days).toEqual([
{ id: days[3].id, day_number: 4, date: '2026-07-04', reason: 'overflow' },
{ id: days[4].id, day_number: 5, date: '2026-07-05', reason: 'overflow' },
]);
const renamed = parseToolResult(await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, title: 'Shorter' } })) as Answer;
expect(renamed.trip.title).toBe('Shorter');
expect(renamed).not.toHaveProperty('removed_days');
});
});
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' });
+235 -1
View File
@@ -54,7 +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 { MAX_TRIP_DAYS, resolveDayGridRange, tripSpanDays } 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';
@@ -398,6 +398,240 @@ describe('generateDays', () => {
expect(stillDateless).toHaveLength(1);
expect(getAssignments(stillDateless[0].id)[0].id).toBe(assignment.id);
});
// ── generateDays carries out the shared planDayGrid plan ──────────────────
// The trip dialog warns about lost days by the same plan, so the plan has to
// be exactly what the rebuild did before it was written down in shared.
function addUndatedDay(tripId: number) {
const next = (testDb.prepare('SELECT COALESCE(MAX(day_number), 0) + 1 AS n FROM days WHERE trip_id = ?').get(tripId) as { n: number }).n;
const id = Number(testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(tripId, next).lastInsertRowid);
return id;
}
function addStay(tripId: number, placeId: number, startDayId: number, endDayId: number) {
return Number(testDb.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)',
).run(tripId, placeId, startDayId, endDayId).lastInsertRowid);
}
const stayExists = (id: number) => !!testDb.prepare('SELECT 1 FROM day_accommodations WHERE id = ?').get(id);
it('TRIP-SVC-070: a stay from a removed day to a spare day goes, and the spare day it left empty goes too', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' });
const days = getDays(trip.id);
const spare = addUndatedDay(trip.id);
const place = createPlace(testDb, trip.id);
const stay = addStay(trip.id, place.id, days[4].id, spare);
const plan = svc.generateDays(trip.id, '2025-07-01', '2025-07-04');
expect(plan.removed.map(r => [r.id, r.reason])).toEqual([[days[4].id, 'overflow'], [spare, 'spare']]);
expect(stayExists(stay)).toBe(false);
expect(getDays(trip.id).map(d => d.id)).toEqual(days.slice(0, 4).map(d => d.id));
});
it('TRIP-SVC-071: only moving the dates drops an empty spare day and keeps one with a note', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-03' });
const empty = addUndatedDay(trip.id);
const noted = addUndatedDay(trip.id);
createDayNote(testDb, noted, trip.id, { text: 'Buffer' });
const plan = svc.generateDays(trip.id, '2025-07-11', '2025-07-13');
expect(plan.removed).toEqual([{ id: empty, day_number: 4, date: null, reason: 'spare' }]);
const after = getDays(trip.id);
expect(after.map(d => d.date)).toEqual(['2025-07-11', '2025-07-12', '2025-07-13', null]);
expect(after[3]).toMatchObject({ id: noted, day_number: 4 });
expect(getNotes(noted)).toHaveLength(1);
});
it('TRIP-SVC-072: without dates only empty days are trimmed, the highest numbers first', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const ids = Array.from({ length: 6 }, () => addUndatedDay(trip.id));
const place = createPlace(testDb, trip.id);
createDayAssignment(testDb, ids[1], place.id);
createDayAssignment(testDb, ids[5], place.id);
const plan = svc.generateDays(trip.id, null, null, 3);
expect(plan.removed.map(r => r.id)).toEqual([ids[4], ids[3], ids[2]]);
expect(getDays(trip.id).map(d => [d.id, d.day_number])).toEqual([[ids[0], 1], [ids[1], 2], [ids[5], 3]]);
});
it('TRIP-SVC-073: generateDays returns the plan it carried out', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-03' });
const before = getDays(trip.id);
const spare = addUndatedDay(trip.id);
const plan = svc.generateDays(trip.id, '2025-07-01', '2025-07-06');
const after = getDays(trip.id);
expect(plan.removed).toEqual([]);
expect(plan.rows.map(r => r.date)).toEqual(after.map(d => d.date));
expect(plan.rows.slice(0, 4).map(r => r.id)).toEqual([...before.map(d => d.id), spare]);
// New rows come back without an id and are the rows the insert created.
expect(plan.rows.slice(4).map(r => r.id)).toEqual([null, null]);
expect(after.slice(4).every(d => !before.some(b => b.id === d.id) && d.id !== spare)).toBe(true);
const shrink = svc.generateDays(trip.id, '2025-07-01', '2025-07-02');
expect(shrink.removed.map(r => r.id)).toEqual(after.slice(2).map(d => d.id));
expect(getDays(trip.id).map(d => d.id)).toEqual(shrink.rows.map(r => r.id));
});
it('TRIP-SVC-074: fuzz, 300 random day grids end exactly where the rebuild before the shared plan left them', () => {
// The rebuild as it stood before planDayGrid, kept here as the oracle.
function legacyGenerateDays(tripId: number, startDate: string | null, endDate: string | null, dayCount?: number) {
const existing = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[];
const setDayNumber = testDb.prepare('UPDATE days SET day_number = ? WHERE id = ?');
const renumber = (list: { id: number }[]) => {
list.forEach((d, i) => setDayNumber.run(-(i + 1), d.id));
list.forEach((d, i) => setDayNumber.run(i + 1, d.id));
};
if (!startDate || !endDate) {
for (const d of existing.filter(d => d.date)) testDb.prepare('UPDATE days SET date = NULL WHERE id = ?').run(d.id);
const all = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
const target = Math.min(Math.max(dayCount ?? (all.length || 7), 1), MAX_TRIP_DAYS);
const needed = target - all.length;
if (needed > 0) {
for (let i = 0; i < needed; i++) testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(tripId, all.length + i + 1);
} else if (needed < 0) {
const candidates = testDb.prepare(
`SELECT d.id FROM days d WHERE d.trip_id = ?
AND NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = d.id)
AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = d.id)
AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = d.id OR dac.end_day_id = d.id)
ORDER BY d.day_number DESC LIMIT ?`,
).all(tripId, -needed) as { id: number }[];
for (const d of candidates) testDb.prepare('DELETE FROM days WHERE id = ?').run(d.id);
}
renumber(testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]);
return;
}
const numDays = tripSpanDays(startDate, endDate);
const targetDates = Array.from({ length: Math.max(numDays, 0) }, (_, i) => addDaysIso(startDate, i));
const dated = existing.filter(d => d.date).sort((a, b) => a.day_number - b.day_number);
const dateless = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number);
[...dated, ...dateless].forEach((d, i) => setDayNumber.run(-(i + 1), d.id));
const assignDay = testDb.prepare('UPDATE days SET date = ?, day_number = ? WHERE id = ?');
let datelessIdx = 0;
targetDates.forEach((date, i) => {
if (i < dated.length) assignDay.run(date, i + 1, dated[i].id);
else if (datelessIdx < dateless.length) assignDay.run(date, i + 1, dateless[datelessIdx++].id);
else testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, i + 1, date);
});
for (let i = targetDates.length; i < dated.length; i++) testDb.prepare('DELETE FROM days WHERE id = ?').run(dated[i].id);
const isEmpty = testDb.prepare(
`SELECT NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = @id)
AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = @id)
AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = @id OR dac.end_day_id = @id) AS empty`,
);
const maxAssigned = Math.max(targetDates.length, dated.length);
let kept = 0;
for (let i = datelessIdx; i < dateless.length; i++) {
if ((isEmpty.get({ id: dateless[i].id }) as { empty: number }).empty) testDb.prepare('DELETE FROM days WHERE id = ?').run(dateless[i].id);
else setDayNumber.run(maxAssigned + ++kept, dateless[i].id);
}
renumber(testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]);
}
// A seeded generator, so a failure names a grid that can be replayed.
let seed = 20260923;
const rand = () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const int = (lo: number, hi: number) => lo + Math.floor(rand() * (hi - lo + 1));
const snapshot = (tripId: number) => ({
days: testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId),
stays: testDb.prepare('SELECT id FROM day_accommodations WHERE trip_id = ? ORDER BY id').all(tripId),
assignments: testDb.prepare(
'SELECT da.id, da.day_id FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ? ORDER BY da.id',
).all(tripId),
notes: testDb.prepare('SELECT id, day_id FROM day_notes WHERE trip_id = ? ORDER BY id').all(tripId),
});
const ROLLBACK = new Error('rollback');
const { user } = createUser(testDb);
let removing = 0;
for (let round = 0; round < 300; round++) {
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const dayIds: number[] = [];
const count = int(0, 9);
for (let n = 1; n <= count; n++) {
const date = rand() < 0.7 ? addDaysIso('2026-03-20', int(0, 20)) : null;
const id = Number(testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(trip.id, n, date).lastInsertRowid);
dayIds.push(id);
if (rand() < 0.3) createDayAssignment(testDb, id, place.id);
if (rand() < 0.2) createDayNote(testDb, id, trip.id);
}
if (dayIds.length > 0) {
for (let k = int(0, 3); k > 0; k--) addStay(trip.id, place.id, dayIds[int(0, dayIds.length - 1)], dayIds[int(0, dayIds.length - 1)]);
}
let range: [string | null, string | null, number | undefined];
if (rand() < 0.25) {
range = [null, null, rand() < 0.4 ? undefined : int(1, 12)];
} else {
const start = addDaysIso('2026-03-15', int(0, 20));
range = [start, addDaysIso(start, int(0, 11)), rand() < 0.8 ? undefined : int(1, 12)];
}
const before = snapshot(trip.id);
let expected: ReturnType<typeof snapshot> | undefined;
try {
testDb.transaction(() => {
legacyGenerateDays(trip.id, ...range);
expected = snapshot(trip.id);
throw ROLLBACK;
})();
} catch (err) {
if (err !== ROLLBACK) throw err;
}
expect(snapshot(trip.id), `round ${round}: the oracle must leave no trace`).toEqual(before);
const plan = svc.generateDays(trip.id, ...range);
const actual = snapshot(trip.id);
expect(actual, `round ${round}: ${JSON.stringify({ range, before: before.days })}`).toEqual(expected);
const survivors = new Set((actual.days as { id: number }[]).map(d => d.id));
expect(plan.removed.map(r => r.id).sort((a, b) => a - b), `round ${round}: removed`)
.toEqual((before.days as { id: number }[]).map(d => d.id).filter(id => !survivors.has(id)).sort((a, b) => a - b));
if (plan.removed.length > 0) removing++;
}
// The grids are random, not easy: plenty of them lose days.
expect(removing).toBeGreaterThan(60);
});
it('TRIP-SVC-075: resolveRange reads a range the way the shared resolveDayGridRange does, a day_count of 0 included', () => {
const { user } = createUser(testDb);
const dated = svc.getRaw(createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }).id)!;
const undated = svc.getRaw(createTrip(testDb, user.id).id)!;
const resolve = (trip: typeof dated, data: Parameters<typeof svc.updateTrip>[2]) => svc['resolveRange'](trip, data);
const cases: [typeof dated, Parameters<typeof svc.updateTrip>[2]][] = [
[dated, {}],
[dated, { title: 'Renamed' }],
[dated, { end_date: '2025-07-03' }],
[dated, { start_date: '2025-07-03', end_date: '2025-07-05' }],
[dated, { start_date: null, end_date: null }],
[dated, { start_date: null, end_date: null, day_count: 3 }],
[undated, { day_count: 0 }],
[undated, { day_count: 4 }],
[undated, { day_count: MAX_TRIP_DAYS + 1 }],
[undated, { start_date: '2025-07-01', end_date: '2025-07-02' }],
];
for (const [trip, data] of cases) expect(resolve(trip, data), JSON.stringify(data)).toEqual(resolveDayGridRange(trip, data));
expect(resolve(undated, { day_count: 0 }).regenerate).toBe(false);
// The refusals stay on the server side of the rule.
expect(() => resolve(dated, { start_date: '2025-07-05', end_date: '2025-07-01' })).toThrow('End date must be after start date');
expect(() => resolve(dated, { end_date: '2025-06-30' })).toThrow('End date must be after start date');
});
});
// ── deleteOldCover — path containment ──────────────────────────────────────────
+16 -1
View File
@@ -95,7 +95,22 @@ const dashboard: TranslationStrings = {
'تنتقل خطط الأيام مع التواريخ الجديدة، بينما تبقى الحجوزات وأماكن الإقامة في تواريخها الأصلية ما دامت تلك التواريخ لا تزال ضمن الرحلة.',
'dashboard.dateShiftAll': 'نقل كل شيء',
'dashboard.dateShiftAllDesc': 'ينتقل خط سير الرحلة بالكامل مع التواريخ الجديدة، بما في ذلك الحجوزات وأماكن الإقامة.',
'dashboard.dateShiftHint': 'نصيحة: لنقل جزء من خط سير الرحلة فقط، استخدم "إضافة يوم" في مخطط الرحلة بدلاً من ذلك.',
'dashboard.dateShiftHint':
'نصيحة: لنقل جزء من خط سير الرحلة فقط، أضف يومًا من "إعادة ترتيب الأيام" في مخطط الرحلة ثم انقله إلى المكان الذي يجب أن تكون فيه الفجوة.',
'dashboard.shrinkTitle': 'إزالة أيام؟',
'dashboard.shrinkIntro': 'عند حفظ التواريخ الجديدة تُزال هذه الأيام:',
'dashboard.shrinkMoreDays': '+{count} أخرى',
'dashboard.shrinkLastDays': 'تُزال الأيام الأخيرة، لا الأولى',
'dashboard.shrinkLastDaysHint':
'تنتقل خطط الأيام مع التواريخ الجديدة، لذا تُزال دائمًا الأيام الأخيرة من خطتك، حتى عندما تغيّر تاريخ البداية.',
'dashboard.shrinkBookingsHint': 'تبقى ضمن الحجوزات. والحجز الذي لا يزال تاريخه ضمن الرحلة يعود إلى ذلك اليوم.',
'dashboard.shrinkBookingsShiftHint': 'تبقى ضمن الحجوزات، دون يوم.',
'dashboard.shrinkStayHint': 'يقع تسجيل الوصول أو المغادرة في يوم تتم إزالته، لذا تُزال الإقامة بالكامل.',
'dashboard.shrinkStayBookedHint':
'يقع تسجيل الوصول أو المغادرة في يوم تتم إزالته، لذا تُزال الإقامة بالكامل. ويبقى حجزها "{booking}" ومصروفه ضمن الحجوزات.',
'dashboard.shrinkConfirm': 'إزالة الأيام والحفظ',
'dashboard.shrinkUnknown':
'تعذّر التحقق من أيام هذه الرحلة. إذا كانت التواريخ الجديدة تضم أيامًا أقل، فإن الحفظ يزيل الأيام الأخيرة وكل ما خُطط فيها.',
'dashboard.filter.planned': 'مخطط لها',
'dashboard.hero.badgeLive': 'مباشر الآن',
'dashboard.hero.badgeToday': 'تبدأ اليوم',
+17 -1
View File
@@ -95,7 +95,23 @@ const dashboard: TranslationStrings = {
'Os planos diários acompanham as novas datas, enquanto reservas e hospedagens permanecem nas datas originais, desde que ainda façam parte da viagem.',
'dashboard.dateShiftAll': 'Mover tudo',
'dashboard.dateShiftAllDesc': 'Todo o itinerário acompanha as novas datas, incluindo reservas e hospedagens.',
'dashboard.dateShiftHint': 'Dica: para mover apenas parte do itinerário, use "Adicionar dia" no planejador.',
'dashboard.dateShiftHint':
'Dica: para mover apenas parte do itinerário, adicione um dia em “Reordenar dias” no planejador e mova-o para onde a lacuna deve ficar.',
'dashboard.shrinkTitle': 'Remover dias?',
'dashboard.shrinkIntro': 'Ao salvar as novas datas, estes dias serão removidos:',
'dashboard.shrinkMoreDays': '+{count} outros',
'dashboard.shrinkLastDays': 'Saem os últimos dias, não os primeiros',
'dashboard.shrinkLastDaysHint':
'Os planos de cada dia acompanham as novas datas, por isso são sempre os últimos dias do plano que saem, mesmo quando o início mudou.',
'dashboard.shrinkBookingsHint':
'Elas continuam em Reservas. Uma cuja data ainda faz parte da viagem volta para esse dia.',
'dashboard.shrinkBookingsShiftHint': 'Elas continuam em Reservas, sem dia.',
'dashboard.shrinkStayHint': 'O check-in ou o check-out é em um dia removido, então a hospedagem inteira é removida.',
'dashboard.shrinkStayBookedHint':
'O check-in ou o check-out é em um dia removido, então a hospedagem inteira é removida. A reserva “{booking}” e a despesa dela continuam em Reservas.',
'dashboard.shrinkConfirm': 'Remover dias e salvar',
'dashboard.shrinkUnknown':
'Não foi possível verificar os dias desta viagem. Se as novas datas tiverem menos dias, salvar remove os últimos dias e tudo o que está planejado neles.',
'dashboard.greeting.morning': 'Bom dia,',
'dashboard.greeting.afternoon': 'Boa tarde,',
'dashboard.greeting.evening': 'Boa noite,',
+16 -1
View File
@@ -181,7 +181,22 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAll': 'Mou-ho tot',
'dashboard.dateShiftAllDesc': "Tot l'itinerari es mou amb les noves dates, incloses les reserves i els allotjaments.",
'dashboard.dateShiftHint':
'Consell: per moure només una part de l\'itinerari, fes servir "Afegeix un dia" al planificador.',
"Consell: per moure només una part de l'itinerari, afegeix un dia a «Reordena els dies» del planificador i mou-lo on ha de quedar el buit.",
'dashboard.shrinkTitle': 'Vols treure dies?',
'dashboard.shrinkIntro': 'En desar les noves dates es treuen aquests dies:',
'dashboard.shrinkMoreDays': '+{count} més',
'dashboard.shrinkLastDays': 'Es treuen els últims dies, no els primers',
'dashboard.shrinkLastDaysHint':
"Els plans de cada dia es mouen amb les noves dates, així que sempre es treuen els últims dies del pla, també quan ha canviat l'inici.",
'dashboard.shrinkBookingsHint':
'Es queden a Reserves. Les que tenen una data que encara és dins del viatge tornen a aquell dia.',
'dashboard.shrinkBookingsShiftHint': 'Es queden a Reserves, sense dia.',
'dashboard.shrinkStayHint': "L'entrada o la sortida és un dia que es treu, així que es treu tota l'estada.",
'dashboard.shrinkStayBookedHint':
"L'entrada o la sortida és un dia que es treu, així que es treu tota l'estada. La seva reserva «{booking}» i la seva despesa es queden a Reserves.",
'dashboard.shrinkConfirm': 'Treu els dies i desa',
'dashboard.shrinkUnknown':
"No s'han pogut comprovar els dies d'aquest viatge. Si les noves dates tenen menys dies, en desar es treuen els últims dies i tot el que hi ha planificat.",
'dashboard.subscribeAllTrips': 'Subscriu-te a tots els viatges',
'dashboard.subscribeAllTripsDesc':
'Un sol canal de calendari per a tots els teus viatges actius, sincronitzat automàticament. Exclou els viatges arxivats i els que van acabar fa més de 90 dies.',
+16 -1
View File
@@ -96,7 +96,22 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAll': 'Posunout vše',
'dashboard.dateShiftAllDesc': 'Celý itinerář se posune podle nových termínů, včetně rezervací a ubytování.',
'dashboard.dateShiftHint':
'Tip: pokud chcete posunout jen část itineráře, použijte místo toho „Přidat den” v plánovači.',
'Tip: pokud chcete posunout jen část itineráře, přidejte v plánovači v „Změnit pořadí dnů“ den a přesuňte ho tam, kde má vzniknout mezera.',
'dashboard.shrinkTitle': 'Odebrat dny?',
'dashboard.shrinkIntro': 'Uložením nových dat se odeberou tyto dny:',
'dashboard.shrinkMoreDays': '+{count} další',
'dashboard.shrinkLastDays': 'Odeberou se poslední dny, ne první',
'dashboard.shrinkLastDaysHint':
'Plány dnů se posouvají s novými daty, takže se vždy odeberou poslední dny plánu, i když se posunul začátek.',
'dashboard.shrinkBookingsHint':
'Zůstanou v Rezervacích. Rezervace, jejíž datum je stále součástí cesty, se vrátí na tento den.',
'dashboard.shrinkBookingsShiftHint': 'Zůstanou v Rezervacích, bez dne.',
'dashboard.shrinkStayHint': 'Příjezd nebo odjezd připadá na odebíraný den, proto se odebere celé ubytování.',
'dashboard.shrinkStayBookedHint':
'Příjezd nebo odjezd připadá na odebíraný den, proto se odebere celé ubytování. Jeho rezervace „{booking}“ a její výdaj zůstanou v Rezervacích.',
'dashboard.shrinkConfirm': 'Odebrat dny a uložit',
'dashboard.shrinkUnknown':
'Dny této cesty se nepodařilo ověřit. Pokud mají nová data méně dnů, uložení odebere poslední dny a vše, co je na nich naplánováno.',
'dashboard.greeting.morning': 'Dobré ráno,',
'dashboard.greeting.afternoon': 'Dobré odpoledne,',
'dashboard.greeting.evening': 'Dobrý večer,',
+17 -1
View File
@@ -99,7 +99,23 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAllDesc':
'Der gesamte Reiseplan wandert mit den neuen Daten, einschließlich Reservierungen und Unterkünften.',
'dashboard.dateShiftHint':
'Tipp: Um nur einen Teil deines Reiseplans zu verschieben, nutze stattdessen "Tag hinzufügen" im Planer.',
'Tipp: Um nur einen Teil deines Reiseplans zu verschieben, füge im Planer unter „Tage neu anordnen“ einen Tag hinzu und schiebe ihn an die Stelle, an der die Lücke entstehen soll.',
'dashboard.shrinkTitle': 'Tage entfernen?',
'dashboard.shrinkIntro': 'Mit den neuen Daten entfallen beim Speichern diese Tage:',
'dashboard.shrinkMoreDays': '+{count} weitere',
'dashboard.shrinkLastDays': 'Es fallen die letzten Tage weg, nicht die ersten',
'dashboard.shrinkLastDaysHint':
'Die Tagespläne wandern mit den neuen Daten, deshalb entfallen immer die letzten Tage deines Plans, auch wenn sich der Start verschoben hat.',
'dashboard.shrinkBookingsHint':
'Sie bleiben unter Buchungen. Liegt ihr Datum noch in der Reise, kommen sie wieder auf diesen Tag.',
'dashboard.shrinkBookingsShiftHint': 'Sie bleiben unter Buchungen, ohne Tag.',
'dashboard.shrinkStayHint':
'Check-in oder Check-out an einem entfallenden Tag, deshalb wird die ganze Unterkunft entfernt.',
'dashboard.shrinkStayBookedHint':
'Check-in oder Check-out an einem entfallenden Tag, deshalb wird die ganze Unterkunft entfernt. Ihre Buchung „{booking}“ und deren Ausgabe bleiben unter Buchungen.',
'dashboard.shrinkConfirm': 'Tage entfernen und speichern',
'dashboard.shrinkUnknown':
'Die Tage dieser Reise ließen sich nicht prüfen. Haben die neuen Daten weniger Tage, entfernt das Speichern die letzten Tage mit allem, was darauf geplant ist.',
'dashboard.greeting.morning': 'Guten Morgen,',
'dashboard.greeting.afternoon': 'Guten Tag,',
'dashboard.greeting.evening': 'Guten Abend,',
+17 -1
View File
@@ -110,7 +110,23 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAll': 'Shift everything',
'dashboard.dateShiftAllDesc':
'The entire itinerary moves with the new dates, including reservations and accommodations.',
'dashboard.dateShiftHint': 'Tip: to shift only part of your itinerary, use "Add day" in the planner instead.',
'dashboard.dateShiftHint':
'Tip: to shift only part of your itinerary, add a day under "Reorder days" in the planner and move it to where the gap belongs.',
'dashboard.shrinkTitle': 'Remove days?',
'dashboard.shrinkIntro': 'Saving the new dates removes these days:',
'dashboard.shrinkMoreDays': '+{count} more',
'dashboard.shrinkLastDays': 'The last days go, not the first',
'dashboard.shrinkLastDaysHint':
'Day plans move with the new dates, so it is always the last days of your plan that go, also when the start moved.',
'dashboard.shrinkBookingsHint':
'They stay under Bookings. One whose date is still part of the trip goes back onto that day.',
'dashboard.shrinkBookingsShiftHint': 'They stay under Bookings, without a day.',
'dashboard.shrinkStayHint': 'Checks in or out on a removed day, so the whole stay is removed.',
'dashboard.shrinkStayBookedHint':
'Checks in or out on a removed day, so the whole stay is removed. Its booking "{booking}" and its expense stay under Bookings.',
'dashboard.shrinkConfirm': 'Remove days and save',
'dashboard.shrinkUnknown':
'The days of this trip could not be checked. If the new dates hold fewer days, saving removes the last days and everything planned on them.',
'dashboard.greeting.morning': 'Good morning,',
'dashboard.greeting.afternoon': 'Good afternoon,',
'dashboard.greeting.evening': 'Good evening,',
+17 -1
View File
@@ -97,7 +97,23 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAll': 'Mover todo',
'dashboard.dateShiftAllDesc':
'Todo el itinerario se mueve con las nuevas fechas, incluidas las reservas y los alojamientos.',
'dashboard.dateShiftHint': 'Consejo: para mover solo una parte del itinerario, usa "Añadir día" en el planificador.',
'dashboard.dateShiftHint':
'Consejo: para mover solo una parte del itinerario, añade un día en «Reordenar días» del planificador y muévelo al lugar donde debe quedar el hueco.',
'dashboard.shrinkTitle': '¿Quitar días?',
'dashboard.shrinkIntro': 'Al guardar las nuevas fechas se quitan estos días:',
'dashboard.shrinkMoreDays': '+{count} más',
'dashboard.shrinkLastDays': 'Se quitan los últimos días, no los primeros',
'dashboard.shrinkLastDaysHint':
'Los planes de cada día se mueven con las nuevas fechas, así que siempre se quitan los últimos días del plan, también cuando cambia el inicio.',
'dashboard.shrinkBookingsHint':
'Se quedan en Reservas. Las que tienen una fecha que sigue dentro del viaje vuelven a ese día.',
'dashboard.shrinkBookingsShiftHint': 'Se quedan en Reservas, sin día.',
'dashboard.shrinkStayHint': 'La entrada o la salida es un día que se quita, así que se quita la estancia entera.',
'dashboard.shrinkStayBookedHint':
'La entrada o la salida es un día que se quita, así que se quita la estancia entera. Su reserva «{booking}» y su gasto se quedan en Reservas.',
'dashboard.shrinkConfirm': 'Quitar días y guardar',
'dashboard.shrinkUnknown':
'No se pudieron comprobar los días de este viaje. Si las nuevas fechas tienen menos días, al guardar se quitan los últimos días y todo lo planificado en ellos.',
'dashboard.greeting.morning': 'Buenos días,',
'dashboard.greeting.afternoon': 'Buenas tardes,',
'dashboard.greeting.evening': 'Buenas noches,',
+16 -1
View File
@@ -100,7 +100,22 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAllDesc':
"L'itinéraire entier suit les nouvelles dates, y compris les réservations et hébergements.",
'dashboard.dateShiftHint':
"Astuce : pour ne décaler qu'une partie de votre itinéraire, utilisez plutôt « Ajouter un jour » dans le planificateur.",
"Astuce : pour ne décaler qu'une partie de votre itinéraire, ajoutez un jour dans « Réorganiser les jours » du planificateur et placez-le là où l'écart doit se créer.",
'dashboard.shrinkTitle': 'Retirer des jours ?',
'dashboard.shrinkIntro': 'En enregistrant les nouvelles dates, ces jours sont retirés :',
'dashboard.shrinkMoreDays': '+{count} de plus',
'dashboard.shrinkLastDays': 'Ce sont les derniers jours qui partent, pas les premiers',
'dashboard.shrinkLastDaysHint':
'Les programmes des jours suivent les nouvelles dates : ce sont donc toujours les derniers jours de votre plan qui sont retirés, même quand le début a changé.',
'dashboard.shrinkBookingsHint':
'Elles restent dans Réservations. Celles dont la date fait encore partie du voyage reviennent sur ce jour.',
'dashboard.shrinkBookingsShiftHint': 'Elles restent dans Réservations, sans jour.',
'dashboard.shrinkStayHint': "L'arrivée ou le départ tombe un jour retiré, le séjour entier est donc retiré.",
'dashboard.shrinkStayBookedHint':
"L'arrivée ou le départ tombe un jour retiré, le séjour entier est donc retiré. Sa réservation « {booking} » et sa dépense restent dans Réservations.",
'dashboard.shrinkConfirm': 'Retirer les jours et enregistrer',
'dashboard.shrinkUnknown':
"Les jours de ce voyage n'ont pas pu être vérifiés. Si les nouvelles dates comptent moins de jours, l'enregistrement retire les derniers jours et tout ce qui y est prévu.",
'dashboard.greeting.morning': 'Bonjour,',
'dashboard.greeting.afternoon': 'Bon après-midi,',
'dashboard.greeting.evening': 'Bonsoir,',
+17 -1
View File
@@ -110,7 +110,23 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAllDesc':
'Ολόκληρο το δρομολόγιο μετακινείται με τις νέες ημερομηνίες, συμπεριλαμβανομένων των κρατήσεων και των καταλυμάτων.',
'dashboard.dateShiftHint':
'Συμβουλή: για να μετατοπίσετε μόνο ένα μέρος του δρομολογίου σας, χρησιμοποιήστε την επιλογή "Προσθήκη ημέρας" στο ημερήσιο πλάνο.',
'Συμβουλή: για να μετατοπίσετε μόνο ένα μέρος του δρομολογίου σας, προσθέστε μια ημέρα από την «Αναδιάταξη ημερών» στο ημερήσιο πλάνο και μετακινήστε την εκεί όπου πρέπει να μείνει το κενό.',
'dashboard.shrinkTitle': 'Αφαίρεση ημερών;',
'dashboard.shrinkIntro': 'Με την αποθήκευση των νέων ημερομηνιών αφαιρούνται αυτές οι ημέρες:',
'dashboard.shrinkMoreDays': '+{count} ακόμη',
'dashboard.shrinkLastDays': 'Αφαιρούνται οι τελευταίες ημέρες, όχι οι πρώτες',
'dashboard.shrinkLastDaysHint':
'Τα πλάνα των ημερών μετακινούνται μαζί με τις νέες ημερομηνίες, οπότε αφαιρούνται πάντα οι τελευταίες ημέρες του πλάνου σας, ακόμη κι όταν άλλαξε η αρχή.',
'dashboard.shrinkBookingsHint':
'Παραμένουν στις Κρατήσεις. Όσες έχουν ημερομηνία που ανήκει ακόμη στο ταξίδι επιστρέφουν σε εκείνη την ημέρα.',
'dashboard.shrinkBookingsShiftHint': 'Παραμένουν στις Κρατήσεις, χωρίς ημέρα.',
'dashboard.shrinkStayHint':
'Η άφιξη ή η αναχώρηση πέφτει σε ημέρα που αφαιρείται, οπότε αφαιρείται ολόκληρη η διαμονή.',
'dashboard.shrinkStayBookedHint':
'Η άφιξη ή η αναχώρηση πέφτει σε ημέρα που αφαιρείται, οπότε αφαιρείται ολόκληρη η διαμονή. Η κράτησή της «{booking}» και το έξοδό της παραμένουν στις Κρατήσεις.',
'dashboard.shrinkConfirm': 'Αφαίρεση ημερών και αποθήκευση',
'dashboard.shrinkUnknown':
'Δεν ήταν δυνατός ο έλεγχος των ημερών αυτού του ταξιδιού. Αν οι νέες ημερομηνίες έχουν λιγότερες ημέρες, η αποθήκευση αφαιρεί τις τελευταίες ημέρες και ό,τι έχει προγραμματιστεί σε αυτές.',
'dashboard.greeting.morning': 'Καλημέρα,',
'dashboard.greeting.afternoon': 'Καλό απόγευμα,',
'dashboard.greeting.evening': 'Καλησπέρα,',
+17 -1
View File
@@ -99,7 +99,23 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAllDesc':
'A teljes útiterv az új dátumokkal együtt mozog, beleértve a foglalásokat és a szállásokat is.',
'dashboard.dateShiftHint':
'Tipp: ha csak az útiterv egy részét szeretnéd eltolni, használd inkább a "Nap hozzáadása" funkciót a tervezőben.',
'Tipp: ha csak az útiterv egy részét szeretnéd eltolni, a tervezőben a „Napok átrendezése” alatt adj hozzá egy napot, és vidd oda, ahol a résnek lennie kell.',
'dashboard.shrinkTitle': 'Eltávolítod a napokat?',
'dashboard.shrinkIntro': 'Az új dátumok mentésekor ezek a napok eltávolításra kerülnek:',
'dashboard.shrinkMoreDays': '+{count} további',
'dashboard.shrinkLastDays': 'Az utolsó napok kerülnek ki, nem az elsők',
'dashboard.shrinkLastDaysHint':
'A napi tervek az új dátumokkal együtt mozognak, ezért mindig a terv utolsó napjai kerülnek ki, akkor is, ha a kezdés változott.',
'dashboard.shrinkBookingsHint':
'A Foglalások között maradnak. Amelyiknek a dátuma még az utazásba esik, visszakerül arra a napra.',
'dashboard.shrinkBookingsShiftHint': 'A Foglalások között maradnak, nap nélkül.',
'dashboard.shrinkStayHint':
'A bejelentkezés vagy a kijelentkezés egy eltávolított napra esik, ezért az egész szállás eltávolításra kerül.',
'dashboard.shrinkStayBookedHint':
'A bejelentkezés vagy a kijelentkezés egy eltávolított napra esik, ezért az egész szállás eltávolításra kerül. A(z) „{booking}” foglalás és annak kiadása a Foglalások között marad.',
'dashboard.shrinkConfirm': 'Napok eltávolítása és mentés',
'dashboard.shrinkUnknown':
'Az utazás napjait nem sikerült ellenőrizni. Ha az új dátumok kevesebb napot fognak át, a mentés eltávolítja az utolsó napokat és mindent, ami rájuk van tervezve.',
'dashboard.greeting.morning': 'Jó reggelt,',
'dashboard.greeting.afternoon': 'Jó napot,',
'dashboard.greeting.evening': 'Jó estét,',
+16 -1
View File
@@ -98,7 +98,22 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAllDesc':
'Seluruh rencana perjalanan berpindah ke tanggal baru, termasuk reservasi dan akomodasi.',
'dashboard.dateShiftHint':
'Tip: untuk menggeser sebagian rencana perjalanan saja, gunakan "Tambah hari" di perencana.',
'Tip: untuk menggeser sebagian rencana perjalanan saja, tambahkan satu hari di “Atur ulang hari” pada perencana lalu pindahkan ke tempat jeda seharusnya berada.',
'dashboard.shrinkTitle': 'Hapus hari?',
'dashboard.shrinkIntro': 'Menyimpan tanggal baru akan menghapus hari-hari ini:',
'dashboard.shrinkMoreDays': '+{count} lainnya',
'dashboard.shrinkLastDays': 'Yang terhapus hari-hari terakhir, bukan yang pertama',
'dashboard.shrinkLastDaysHint':
'Rencana harian ikut bergeser bersama tanggal baru, jadi yang terhapus selalu hari-hari terakhir rencana Anda, juga saat tanggal mulai berubah.',
'dashboard.shrinkBookingsHint':
'Tetap ada di Pemesanan. Pemesanan yang tanggalnya masih termasuk dalam perjalanan kembali ke hari tersebut.',
'dashboard.shrinkBookingsShiftHint': 'Tetap ada di Pemesanan, tanpa hari.',
'dashboard.shrinkStayHint': 'Check-in atau check-out jatuh pada hari yang dihapus, jadi seluruh penginapan dihapus.',
'dashboard.shrinkStayBookedHint':
'Check-in atau check-out jatuh pada hari yang dihapus, jadi seluruh penginapan dihapus. Pemesanan “{booking}” dan pengeluarannya tetap ada di Pemesanan.',
'dashboard.shrinkConfirm': 'Hapus hari dan simpan',
'dashboard.shrinkUnknown':
'Hari-hari perjalanan ini tidak dapat diperiksa. Jika tanggal baru mencakup lebih sedikit hari, menyimpan akan menghapus hari-hari terakhir beserta semua yang direncanakan di dalamnya.',
'dashboard.greeting.morning': 'Selamat pagi,',
'dashboard.greeting.afternoon': 'Selamat siang,',
'dashboard.greeting.evening': 'Selamat malam,',
+17 -1
View File
@@ -99,7 +99,23 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAll': 'Sposta tutto',
'dashboard.dateShiftAllDesc': "L'intero itinerario si sposta con le nuove date, incluse prenotazioni e alloggi.",
'dashboard.dateShiftHint':
'Suggerimento: per spostare solo una parte dell\'itinerario, usa "Aggiungi giorno" nel planner.',
"Suggerimento: per spostare solo una parte dell'itinerario, aggiungi un giorno in «Riordina i giorni» nel planner e spostalo dove deve crearsi lo spazio.",
'dashboard.shrinkTitle': 'Rimuovere dei giorni?',
'dashboard.shrinkIntro': 'Salvando le nuove date vengono rimossi questi giorni:',
'dashboard.shrinkMoreDays': '+{count} altri',
'dashboard.shrinkLastDays': 'Vengono rimossi gli ultimi giorni, non i primi',
'dashboard.shrinkLastDaysHint':
"I programmi dei giorni seguono le nuove date, quindi vengono sempre rimossi gli ultimi giorni del piano, anche quando è cambiato l'inizio.",
'dashboard.shrinkBookingsHint':
'Restano in Prenotazioni. Quelle la cui data fa ancora parte del viaggio tornano su quel giorno.',
'dashboard.shrinkBookingsShiftHint': 'Restano in Prenotazioni, senza giorno.',
'dashboard.shrinkStayHint':
"Il check-in o il check-out cade in un giorno rimosso, quindi viene rimosso l'intero soggiorno.",
'dashboard.shrinkStayBookedHint':
"Il check-in o il check-out cade in un giorno rimosso, quindi viene rimosso l'intero soggiorno. La sua prenotazione «{booking}» e la relativa spesa restano in Prenotazioni.",
'dashboard.shrinkConfirm': 'Rimuovi i giorni e salva',
'dashboard.shrinkUnknown':
'Non è stato possibile controllare i giorni di questo viaggio. Se le nuove date hanno meno giorni, salvando vengono rimossi gli ultimi giorni e tutto ciò che vi è pianificato.',
'dashboard.greeting.morning': 'Buongiorno,',
'dashboard.greeting.afternoon': 'Buon pomeriggio,',
'dashboard.greeting.evening': 'Buonasera,',
+16 -1
View File
@@ -110,7 +110,22 @@ const dashboard: TranslationStrings = {
'日別計画は新しい日程に合わせて移動しますが、予約と宿泊先は旅行期間内にある限り元の日付のままになります。',
'dashboard.dateShiftAll': 'すべてを移動する',
'dashboard.dateShiftAllDesc': '予約や宿泊先を含む旅程全体が新しい日程に合わせて移動します。',
'dashboard.dateShiftHint': 'ヒント:旅程の一部だけを移動したい場合は、プランナーの「日付を追加」を使用してください。',
'dashboard.dateShiftHint':
'ヒント:旅程の一部だけをずらしたい場合は、プランナーの「日付を並べ替え」で日を追加し、空けたい位置へ移動してください。',
'dashboard.shrinkTitle': '日を削除しますか?',
'dashboard.shrinkIntro': '新しい日付を保存すると、次の日が削除されます:',
'dashboard.shrinkMoreDays': 'ほか{count}日',
'dashboard.shrinkLastDays': '削除されるのは最初ではなく最後の日です',
'dashboard.shrinkLastDaysHint':
'各日のプランは新しい日付と一緒に移動するため、開始日を変えた場合でも、削除されるのは常にプランの最後の日です。',
'dashboard.shrinkBookingsHint': '予約に残ります。日付がまだ旅行の期間内にある予約は、その日に戻ります。',
'dashboard.shrinkBookingsShiftHint': '日付なしで予約に残ります。',
'dashboard.shrinkStayHint': '削除される日にチェックインまたはチェックアウトするため、宿泊全体が削除されます。',
'dashboard.shrinkStayBookedHint':
'削除される日にチェックインまたはチェックアウトするため、宿泊全体が削除されます。予約「{booking}」とその支出は予約に残ります。',
'dashboard.shrinkConfirm': '日を削除して保存',
'dashboard.shrinkUnknown':
'この旅行の日を確認できませんでした。新しい日付の日数が少ない場合、保存すると最後の日とそこに予定されているものがすべて削除されます。',
'dashboard.greeting.morning': 'おはようございます、',
'dashboard.greeting.afternoon': 'こんにちは、',
'dashboard.greeting.evening': 'こんばんは、',
+16 -1
View File
@@ -110,7 +110,22 @@ const dashboard: TranslationStrings = {
'일별 계획은 새 날짜로 이동하고, 예약과 숙박은 여행 기간에 포함되어 있는 한 원래 날짜에 그대로 유지됩니다.',
'dashboard.dateShiftAll': '모두 이동',
'dashboard.dateShiftAllDesc': '예약과 숙박을 포함한 전체 일정이 새 날짜로 이동합니다.',
'dashboard.dateShiftHint': '팁: 일정의 일부만 이동하려면 플래너의 "날짜 추가" 기능을 사용하세요.',
'dashboard.dateShiftHint':
'팁: 일정의 일부만 옮기려면 플래너의 "날짜 순서 변경"에서 날짜를 추가한 뒤 빈칸이 생겨야 할 위치로 옮기세요.',
'dashboard.shrinkTitle': '날짜를 삭제할까요?',
'dashboard.shrinkIntro': '새 날짜를 저장하면 다음 날짜가 삭제됩니다:',
'dashboard.shrinkMoreDays': '외 {count}일',
'dashboard.shrinkLastDays': '처음이 아니라 마지막 날짜가 삭제됩니다',
'dashboard.shrinkLastDaysHint':
'각 날짜의 계획은 새 날짜와 함께 이동하므로, 시작일이 바뀌어도 항상 계획의 마지막 날짜가 삭제됩니다.',
'dashboard.shrinkBookingsHint': '예약에 남습니다. 날짜가 아직 여행 기간에 포함된 예약은 해당 날짜로 돌아갑니다.',
'dashboard.shrinkBookingsShiftHint': '날짜 없이 예약에 남습니다.',
'dashboard.shrinkStayHint': '삭제되는 날짜에 체크인 또는 체크아웃하므로 숙박 전체가 삭제됩니다.',
'dashboard.shrinkStayBookedHint':
'삭제되는 날짜에 체크인 또는 체크아웃하므로 숙박 전체가 삭제됩니다. 예약 “{booking}”과 해당 지출은 예약에 남습니다.',
'dashboard.shrinkConfirm': '날짜 삭제 후 저장',
'dashboard.shrinkUnknown':
'이 여행의 날짜를 확인할 수 없습니다. 새 날짜의 일수가 더 적으면 저장 시 마지막 날짜와 그날 계획된 모든 항목이 삭제됩니다.',
'dashboard.greeting.morning': '좋은 아침이에요,',
'dashboard.greeting.afternoon': '안녕하세요,',
'dashboard.greeting.evening': '좋은 저녁이에요,',
+17 -1
View File
@@ -98,7 +98,23 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAllDesc':
'De volledige reisplanning verschuift mee met de nieuwe datums, inclusief reserveringen en accommodaties.',
'dashboard.dateShiftHint':
'Tip: om slechts een deel van je reisplanning te verschuiven, gebruik je "Dag toevoegen" in de planner.',
'Tip: om maar een deel van je reisplanning te verschuiven, voeg je in de planner onder ‘Dagen herordenen’ een dag toe en zet je die op de plek waar het gat moet komen.',
'dashboard.shrinkTitle': 'Dagen verwijderen?',
'dashboard.shrinkIntro': 'Bij het opslaan van de nieuwe datums worden deze dagen verwijderd:',
'dashboard.shrinkMoreDays': '+{count} meer',
'dashboard.shrinkLastDays': 'De laatste dagen vallen weg, niet de eerste',
'dashboard.shrinkLastDaysHint':
'Dagplanningen schuiven mee met de nieuwe datums, dus het zijn altijd de laatste dagen van je planning die wegvallen, ook als het begin is verschoven.',
'dashboard.shrinkBookingsHint':
'Ze blijven onder Boekingen. Een boeking waarvan de datum nog binnen de reis valt, komt terug op die dag.',
'dashboard.shrinkBookingsShiftHint': 'Ze blijven onder Boekingen, zonder dag.',
'dashboard.shrinkStayHint':
'Inchecken of uitchecken valt op een dag die wegvalt, dus het hele verblijf wordt verwijderd.',
'dashboard.shrinkStayBookedHint':
'Inchecken of uitchecken valt op een dag die wegvalt, dus het hele verblijf wordt verwijderd. De boeking ‘{booking}’ en de bijbehorende uitgave blijven onder Boekingen.',
'dashboard.shrinkConfirm': 'Dagen verwijderen en opslaan',
'dashboard.shrinkUnknown':
'De dagen van deze reis konden niet worden gecontroleerd. Hebben de nieuwe datums minder dagen, dan verwijdert opslaan de laatste dagen en alles wat daarop gepland is.',
'dashboard.greeting.morning': 'Goedemorgen,',
'dashboard.greeting.afternoon': 'Goedemiddag,',
'dashboard.greeting.evening': 'Goedenavond,',
+17 -1
View File
@@ -92,7 +92,23 @@ const dashboard: TranslationStrings = {
'Plany dnia przesuną się wraz z nowymi datami, a rezerwacje i noclegi pozostaną w swoich pierwotnych terminach, o ile nadal mieszczą się one w ramach podróży.',
'dashboard.dateShiftAll': 'Przesuń wszystko',
'dashboard.dateShiftAllDesc': 'Cały plan podróży przesunie się wraz z nowymi datami, w tym rezerwacje i noclegi.',
'dashboard.dateShiftHint': 'Wskazówka: aby przesunąć tylko część planu podróży, użyj opcji "Dodaj dzień" w planerze.',
'dashboard.dateShiftHint':
'Wskazówka: aby przesunąć tylko część planu podróży, dodaj dzień w „Zmień kolejność dni” w planerze i przenieś go tam, gdzie ma powstać przerwa.',
'dashboard.shrinkTitle': 'Usunąć dni?',
'dashboard.shrinkIntro': 'Zapisanie nowych dat usunie te dni:',
'dashboard.shrinkMoreDays': '+{count} więcej',
'dashboard.shrinkLastDays': 'Usuwane są ostatnie dni, nie pierwsze',
'dashboard.shrinkLastDaysHint':
'Plany dni przesuwają się razem z nowymi datami, więc zawsze usuwane są ostatnie dni planu, także gdy zmienił się początek.',
'dashboard.shrinkBookingsHint':
'Pozostają w Rezerwacjach. Rezerwacja, której data nadal mieści się w podróży, wraca na ten dzień.',
'dashboard.shrinkBookingsShiftHint': 'Pozostają w Rezerwacjach, bez dnia.',
'dashboard.shrinkStayHint': 'Zameldowanie lub wymeldowanie przypada na usuwany dzień, więc usuwany jest cały nocleg.',
'dashboard.shrinkStayBookedHint':
'Zameldowanie lub wymeldowanie przypada na usuwany dzień, więc usuwany jest cały nocleg. Jego rezerwacja „{booking}” i jej wydatek pozostają w Rezerwacjach.',
'dashboard.shrinkConfirm': 'Usuń dni i zapisz',
'dashboard.shrinkUnknown':
'Nie udało się sprawdzić dni tej podróży. Jeśli nowe daty obejmują mniej dni, zapisanie usunie ostatnie dni i wszystko, co na nie zaplanowano.',
'dashboard.members': 'Współpodróżnicy',
'dashboard.copyTrip': 'Kopiuj',
'dashboard.copySuffix': 'kopia',
+16 -1
View File
@@ -96,7 +96,22 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAll': 'Перенести всё',
'dashboard.dateShiftAllDesc': 'Весь маршрут переносится на новые даты, включая бронирования и жильё.',
'dashboard.dateShiftHint':
'Совет: чтобы перенести только часть маршрута, используйте «Добавить день» в планировщике.',
'Совет: чтобы сдвинуть только часть маршрута, добавьте день в разделе «Изменить порядок дней» в планировщике и переместите его туда, где должен появиться промежуток.',
'dashboard.shrinkTitle': 'Удалить дни?',
'dashboard.shrinkIntro': 'При сохранении новых дат будут удалены эти дни:',
'dashboard.shrinkMoreDays': 'ещё +{count}',
'dashboard.shrinkLastDays': 'Удаляются последние дни, а не первые',
'dashboard.shrinkLastDaysHint':
'Планы дней переносятся вместе с новыми датами, поэтому удаляются всегда последние дни плана, даже если сдвинулось начало.',
'dashboard.shrinkBookingsHint':
'Они останутся в Бронированиях. Бронирование, дата которого ещё входит в поездку, вернётся на этот день.',
'dashboard.shrinkBookingsShiftHint': 'Они останутся в Бронированиях, без дня.',
'dashboard.shrinkStayHint': 'Заезд или выезд приходится на удаляемый день, поэтому проживание удаляется целиком.',
'dashboard.shrinkStayBookedHint':
'Заезд или выезд приходится на удаляемый день, поэтому проживание удаляется целиком. Его бронирование «{booking}» и расход по нему останутся в Бронированиях.',
'dashboard.shrinkConfirm': 'Удалить дни и сохранить',
'dashboard.shrinkUnknown':
'Не удалось проверить дни этой поездки. Если в новых датах меньше дней, при сохранении будут удалены последние дни и всё, что на них запланировано.',
'dashboard.greeting.morning': 'Доброе утро,',
'dashboard.greeting.afternoon': 'Добрый день,',
'dashboard.greeting.evening': 'Добрый вечер,',
+16 -1
View File
@@ -110,7 +110,22 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAll': 'Flytta allt',
'dashboard.dateShiftAllDesc': 'Hela resplanen flyttas med de nya datumen, inklusive bokningar och boenden.',
'dashboard.dateShiftHint':
'Tips: om du bara vill flytta en del av resplanen kan du använda "Lägg till dag" i planeraren istället.',
'Tips: om du bara vill flytta en del av resplanen kan du lägga till en dag under ”Sortera om dagar” i planeraren och flytta den dit luckan ska vara.',
'dashboard.shrinkTitle': 'Ta bort dagar?',
'dashboard.shrinkIntro': 'När de nya datumen sparas tas de här dagarna bort:',
'dashboard.shrinkMoreDays': '+{count} till',
'dashboard.shrinkLastDays': 'Det är de sista dagarna som försvinner, inte de första',
'dashboard.shrinkLastDaysHint':
'Dagsplanerna följer med de nya datumen, så det är alltid de sista dagarna i planen som tas bort, även när starten har flyttats.',
'dashboard.shrinkBookingsHint':
'De ligger kvar under Bokningar. En bokning vars datum fortfarande ingår i resan hamnar på den dagen igen.',
'dashboard.shrinkBookingsShiftHint': 'De ligger kvar under Bokningar, utan dag.',
'dashboard.shrinkStayHint': 'Incheckning eller utcheckning sker en dag som tas bort, så hela boendet tas bort.',
'dashboard.shrinkStayBookedHint':
'Incheckning eller utcheckning sker en dag som tas bort, så hela boendet tas bort. Bokningen ”{booking}” och dess utgift ligger kvar under Bokningar.',
'dashboard.shrinkConfirm': 'Ta bort dagar och spara',
'dashboard.shrinkUnknown':
'Resans dagar kunde inte kontrolleras. Om de nya datumen rymmer färre dagar tar sparandet bort de sista dagarna och allt som är planerat på dem.',
'dashboard.greeting.morning': 'God morgon,',
'dashboard.greeting.afternoon': 'God eftermiddag,',
'dashboard.greeting.evening': 'God kväll,',
+16 -1
View File
@@ -111,7 +111,22 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftAll': 'Her şeyi kaydır',
'dashboard.dateShiftAllDesc': 'Rezervasyonlar ve konaklamalar dahil tüm plan yeni tarihlerle birlikte taşınır.',
'dashboard.dateShiftHint':
'İpucu: planın yalnızca bir kısmını kaydırmak için planlayıcıdaki "Gün ekle" özelliğini kullanın.',
'İpucu: planın yalnızca bir kısmını kaydırmak için planlayıcıda “Günleri yeniden sırala” altında bir gün ekleyin ve boşluğun olacağı yere taşıyın.',
'dashboard.shrinkTitle': 'Günler kaldırılsın mı?',
'dashboard.shrinkIntro': 'Yeni tarihler kaydedilince bu günler kaldırılır:',
'dashboard.shrinkMoreDays': '+{count} daha',
'dashboard.shrinkLastDays': 'İlk günler değil, son günler kaldırılır',
'dashboard.shrinkLastDaysHint':
'Gün planları yeni tarihlerle birlikte kayar, bu yüzden başlangıç değişse bile her zaman planın son günleri kaldırılır.',
'dashboard.shrinkBookingsHint':
'Rezervasyonlar altında kalırlar. Tarihi hâlâ seyahatin içinde olan rezervasyon o güne geri döner.',
'dashboard.shrinkBookingsShiftHint': 'Rezervasyonlar altında, bir güne bağlı olmadan kalırlar.',
'dashboard.shrinkStayHint': 'Giriş veya çıkış kaldırılan bir güne denk geldiği için konaklamanın tamamı kaldırılır.',
'dashboard.shrinkStayBookedHint':
'Giriş veya çıkış kaldırılan bir güne denk geldiği için konaklamanın tamamı kaldırılır. “{booking}” rezervasyonu ve harcaması Rezervasyonlar altında kalır.',
'dashboard.shrinkConfirm': 'Günleri kaldır ve kaydet',
'dashboard.shrinkUnknown':
'Bu seyahatin günleri kontrol edilemedi. Yeni tarihler daha az gün içeriyorsa kaydetmek son günleri ve bunlara planlanan her şeyi kaldırır.',
'dashboard.greeting.morning': 'Günaydın,',
'dashboard.greeting.afternoon': 'İyi günler,',
'dashboard.greeting.evening': 'İyi akşamlar,',
+17 -1
View File
@@ -111,7 +111,23 @@ const dashboard: TranslationStrings = {
'Плани днів переміщуються разом із новими датами, а бронювання та проживання залишаються на своїх початкових датах, поки ті все ще входять до поїздки.',
'dashboard.dateShiftAll': 'Перемістити все',
'dashboard.dateShiftAllDesc': 'Весь маршрут переміщується на нові дати, включно з бронюваннями та проживанням.',
'dashboard.dateShiftHint': 'Порада: щоб зсунути лише частину маршруту, скористайтеся «Додати день» у планувальнику.',
'dashboard.dateShiftHint':
'Порада: щоб зсунути лише частину маршруту, додайте день у розділі «Змінити порядок днів» у планувальнику й перемістіть його туди, де має з’явитися проміжок.',
'dashboard.shrinkTitle': 'Видалити дні?',
'dashboard.shrinkIntro': 'Під час збереження нових дат буде видалено ці дні:',
'dashboard.shrinkMoreDays': 'ще +{count}',
'dashboard.shrinkLastDays': 'Видаляються останні дні, а не перші',
'dashboard.shrinkLastDaysHint':
'Плани днів переносяться разом із новими датами, тому завжди видаляються останні дні плану, навіть якщо зсунувся початок.',
'dashboard.shrinkBookingsHint':
'Вони залишаться в Бронюваннях. Бронювання, дата якого ще входить у подорож, повернеться на цей день.',
'dashboard.shrinkBookingsShiftHint': 'Вони залишаться в Бронюваннях, без дня.',
'dashboard.shrinkStayHint': 'Заїзд або виїзд припадає на день, що видаляється, тому проживання видаляється повністю.',
'dashboard.shrinkStayBookedHint':
'Заїзд або виїзд припадає на день, що видаляється, тому проживання видаляється повністю. Його бронювання «{booking}» і витрата за ним залишаться в Бронюваннях.',
'dashboard.shrinkConfirm': 'Видалити дні й зберегти',
'dashboard.shrinkUnknown':
'Не вдалося перевірити дні цієї подорожі. Якщо в нових датах менше днів, під час збереження буде видалено останні дні й усе, що на них заплановано.',
'dashboard.greeting.morning': 'Доброго ранку,',
'dashboard.greeting.afternoon': 'Доброго дня,',
'dashboard.greeting.evening': 'Доброго вечора,',
+17 -1
View File
@@ -104,7 +104,23 @@ const dashboard: TranslationStrings = {
'Kế hoạch theo ngày sẽ dời theo ngày mới, trong khi đặt chỗ và chỗ ở vẫn giữ nguyên ngày ban đầu miễn là những ngày đó vẫn nằm trong chuyến đi.',
'dashboard.dateShiftAll': 'Dời tất cả',
'dashboard.dateShiftAllDesc': 'Toàn bộ lịch trình sẽ dời theo ngày mới, bao gồm cả đặt chỗ và chỗ ở.',
'dashboard.dateShiftHint': 'Mẹo: để chỉ dời một phần lịch trình, hãy dùng "Thêm ngày" trong trình lập kế hoạch.',
'dashboard.dateShiftHint':
'Mẹo: để chỉ dời một phần lịch trình, hãy thêm một ngày trong “Sắp xếp lại ngày” ở trình lập kế hoạch rồi chuyển nó đến chỗ cần tạo khoảng trống.',
'dashboard.shrinkTitle': 'Bỏ bớt ngày?',
'dashboard.shrinkIntro': 'Khi lưu ngày mới, những ngày này sẽ bị bỏ:',
'dashboard.shrinkMoreDays': '+{count} ngày khác',
'dashboard.shrinkLastDays': 'Những ngày cuối bị bỏ, không phải những ngày đầu',
'dashboard.shrinkLastDaysHint':
'Kế hoạch từng ngày dời theo ngày mới, nên luôn là những ngày cuối của kế hoạch bị bỏ, kể cả khi ngày bắt đầu đã thay đổi.',
'dashboard.shrinkBookingsHint':
'Chúng vẫn nằm trong Đặt chỗ. Đặt chỗ nào có ngày vẫn thuộc chuyến đi sẽ quay lại ngày đó.',
'dashboard.shrinkBookingsShiftHint': 'Chúng vẫn nằm trong Đặt chỗ, không gắn với ngày nào.',
'dashboard.shrinkStayHint': 'Nhận phòng hoặc trả phòng rơi vào một ngày bị bỏ, nên toàn bộ chỗ lưu trú bị bỏ.',
'dashboard.shrinkStayBookedHint':
'Nhận phòng hoặc trả phòng rơi vào một ngày bị bỏ, nên toàn bộ chỗ lưu trú bị bỏ. Đặt chỗ “{booking}” và chi phí của nó vẫn nằm trong Đặt chỗ.',
'dashboard.shrinkConfirm': 'Bỏ ngày và lưu',
'dashboard.shrinkUnknown':
'Không thể kiểm tra các ngày của chuyến đi này. Nếu ngày mới có ít ngày hơn, việc lưu sẽ bỏ những ngày cuối và mọi thứ đã lên kế hoạch trong đó.',
'dashboard.greeting.morning': 'Chào buổi sáng,',
'dashboard.greeting.afternoon': 'Chào buổi chiều,',
'dashboard.greeting.evening': 'Buổi tối vui vẻ,',
+15 -1
View File
@@ -95,7 +95,21 @@ const dashboard: TranslationStrings = {
'每日計劃會隨新日期移動,而預訂和住宿只要仍在旅行期間內,就會保留在原本的日期。',
'dashboard.dateShiftAll': '全部移動',
'dashboard.dateShiftAllDesc': '整個行程都會隨新日期移動,包括預訂和住宿。',
'dashboard.dateShiftHint': '提示:若只想移動部分行程,請改用規劃頁面中的「新增日期」。',
'dashboard.dateShiftHint':
'提示:若只想移動部分行程,請在規劃頁面的「重新排序日期」中新增一天,並將它移到需要空出的位置。',
'dashboard.shrinkTitle': '移除天數?',
'dashboard.shrinkIntro': '儲存新日期後,將移除以下這些天:',
'dashboard.shrinkMoreDays': '另外 {count} 天',
'dashboard.shrinkLastDays': '移除的是最後幾天,而不是最前面的幾天',
'dashboard.shrinkLastDaysHint': '每天的計畫會隨新日期一起移動,因此即使開始日期改變,移除的也總是計畫中的最後幾天。',
'dashboard.shrinkBookingsHint': '它們會保留在「預訂」中。日期仍在旅行範圍內的預訂會回到對應的那一天。',
'dashboard.shrinkBookingsShiftHint': '它們會保留在「預訂」中,但不再屬於任何一天。',
'dashboard.shrinkStayHint': '入住或退房在被移除的某一天,因此整個住宿都會被移除。',
'dashboard.shrinkStayBookedHint':
'入住或退房在被移除的某一天,因此整個住宿都會被移除。其預訂「{booking}」及費用會保留在「預訂」中。',
'dashboard.shrinkConfirm': '移除天數並儲存',
'dashboard.shrinkUnknown':
'無法檢查此旅行的天數。如果新日期包含的天數較少,儲存時會移除最後幾天及其中規劃的所有內容。',
'dashboard.greeting.morning': '早安,',
'dashboard.greeting.afternoon': '午安,',
'dashboard.greeting.evening': '晚安,',
+15 -1
View File
@@ -95,7 +95,21 @@ const dashboard: TranslationStrings = {
'日程计划将随新日期移动,而预订和住宿只要仍在旅行日期范围内,就保留其原定日期。',
'dashboard.dateShiftAll': '全部顺移',
'dashboard.dateShiftAllDesc': '整个行程将随新日期移动,包括预订和住宿。',
'dashboard.dateShiftHint': '提示:如需仅移动部分行程,请改用行程规划中的「添加一天」。',
'dashboard.dateShiftHint':
'提示:如需仅移动部分行程,请在行程规划的“调整日期顺序”中添加一天,并将其移到需要留出空档的位置。',
'dashboard.shrinkTitle': '移除天数?',
'dashboard.shrinkIntro': '保存新日期后,将移除以下这些天:',
'dashboard.shrinkMoreDays': '另外 {count} 天',
'dashboard.shrinkLastDays': '移除的是最后几天,而不是最前面的几天',
'dashboard.shrinkLastDaysHint': '每天的计划会随新日期一起移动,因此即使开始日期变了,移除的也总是计划中的最后几天。',
'dashboard.shrinkBookingsHint': '它们会保留在“预订”中。日期仍在旅行范围内的预订会回到对应的那一天。',
'dashboard.shrinkBookingsShiftHint': '它们会保留在“预订”中,但不再属于任何一天。',
'dashboard.shrinkStayHint': '入住或退房在被移除的某一天,因此整个住宿都会被移除。',
'dashboard.shrinkStayBookedHint':
'入住或退房在被移除的某一天,因此整个住宿都会被移除。其预订“{booking}”及费用会保留在“预订”中。',
'dashboard.shrinkConfirm': '移除天数并保存',
'dashboard.shrinkUnknown':
'无法检查此旅行的天数。如果新日期包含的天数更少,保存时会移除最后几天及其中计划的所有内容。',
'dashboard.greeting.morning': '早上好,',
'dashboard.greeting.afternoon': '下午好,',
'dashboard.greeting.evening': '晚上好,',
+171 -1
View File
@@ -1,4 +1,12 @@
import { addIsoDays, nextTripDate, planDatedAppend } from './day-grid';
import {
type DayGridDay,
type DayGridStay,
addIsoDays,
nextTripDate,
planDatedAppend,
planDayGrid,
resolveDayGridRange,
} from './day-grid';
import { MAX_TRIP_DAYS } from './trip.schema';
import { describe, expect, it } from 'vitest';
@@ -68,3 +76,165 @@ describe('planDatedAppend', () => {
expect(planDatedAppend({}, ['2026-10-10'])).toBeNull();
});
});
describe('resolveDayGridRange', () => {
const dated = { start_date: '2026-10-01', end_date: '2026-10-10' };
it('keeps a stored date the update leaves out, and clears one it sends as null', () => {
expect(resolveDayGridRange(dated, { end_date: '2026-10-08' })).toEqual({
newStart: '2026-10-01',
newEnd: '2026-10-08',
dayCount: undefined,
regenerate: true,
});
expect(resolveDayGridRange(dated, { start_date: null, end_date: null })).toMatchObject({
newStart: null,
newEnd: null,
regenerate: true,
});
});
it('rebuilds nothing when the dates stay and no day count comes', () => {
expect(resolveDayGridRange(dated, {}).regenerate).toBe(false);
expect(resolveDayGridRange(dated, { ...dated }).regenerate).toBe(false);
const undated = { start_date: null, end_date: null };
expect(resolveDayGridRange(undated, { ...undated }).regenerate).toBe(false);
});
it('rebuilds on any day count, held to 1..MAX_TRIP_DAYS, and reads 0 or null as none', () => {
const undated = { start_date: null, end_date: null };
expect(resolveDayGridRange(undated, { day_count: 5 })).toMatchObject({ dayCount: 5, regenerate: true });
expect(resolveDayGridRange(undated, { day_count: MAX_TRIP_DAYS + 5 }).dayCount).toBe(MAX_TRIP_DAYS);
expect(resolveDayGridRange(undated, { day_count: -3 }).dayCount).toBe(1);
expect(resolveDayGridRange(undated, { day_count: 0 })).toMatchObject({ dayCount: undefined, regenerate: false });
expect(resolveDayGridRange(undated, { day_count: null })).toMatchObject({ dayCount: undefined, regenerate: false });
});
});
describe('planDayGrid', () => {
// The trip of the plan: Oct 1 to 10 with a day each (ids 1 to 10), then two
// days without a date: 11 is empty, 12 has a place on it.
const tenDays: DayGridDay[] = [
...Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
day_number: i + 1,
date: addIsoDays('2026-10-01', i),
hasPlanItems: true,
})),
{ id: 11, day_number: 11, date: null, hasPlanItems: false },
{ id: 12, day_number: 12, date: null, hasPlanItems: true },
];
const plan = (startDate: string | null, endDate: string | null, stays: DayGridStay[] = []) =>
planDayGrid({ days: tenDays, stays, startDate, endDate });
const ids = (p: ReturnType<typeof plan>) => p.rows.map((r) => r.id);
const gone = (p: ReturnType<typeof plan>) => p.removed.map((r) => `${r.id}:${r.reason}`);
it('an earlier end takes the last days with everything on them, and the empty spare day', () => {
const p = plan('2026-10-01', '2026-10-08');
expect(ids(p)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 12]);
expect(p.rows.at(-2)).toEqual({ id: 8, date: '2026-10-08' });
expect(p.rows.at(-1)).toEqual({ id: 12, date: null });
expect(gone(p)).toEqual(['9:overflow', '10:overflow', '11:spare']);
expect(p.removed[0]).toEqual({ id: 9, day_number: 9, date: '2026-10-09', reason: 'overflow' });
});
it('a later start and the same end still takes the last days, the plans move with the dates', () => {
const p = plan('2026-10-03', '2026-10-10');
expect(p.rows.slice(0, 8)).toEqual(
Array.from({ length: 8 }, (_, i) => ({ id: i + 1, date: addIsoDays('2026-10-03', i) })),
);
expect(gone(p)).toEqual(['9:overflow', '10:overflow', '11:spare']);
});
it('moving the whole range keeps every dated day and drops only the empty spare day', () => {
const p = plan('2026-10-05', '2026-10-14');
expect(ids(p)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12]);
expect(p.rows[9]).toEqual({ id: 10, date: '2026-10-14' });
expect(gone(p)).toEqual(['11:spare']);
});
it('a later end dates the first spare day and removes nothing', () => {
const p = plan('2026-10-01', '2026-10-11');
expect(p.rows.slice(9)).toEqual([
{ id: 10, date: '2026-10-10' },
{ id: 11, date: '2026-10-11' },
{ id: 12, date: null },
]);
expect(p.removed).toEqual([]);
});
it('a range longer than all days adds new empty days after the spare ones', () => {
const p = plan('2026-10-01', '2026-10-14');
expect(ids(p)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, null, null]);
expect(p.rows.at(-1)).toEqual({ id: null, date: '2026-10-14' });
expect(p.removed).toEqual([]);
});
it('clearing the dates without a count keeps every day and takes all dates away', () => {
const p = plan(null, null);
expect(ids(p)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
expect(p.rows.every((r) => r.date === null)).toBe(true);
expect(p.removed).toEqual([]);
});
it('a stay from a removed day to a spare day goes with the removed day, so the spare day is empty', () => {
const p = plan('2026-10-01', '2026-10-09', [{ start_day_id: 10, end_day_id: 11 }]);
expect(gone(p)).toEqual(['10:overflow', '11:spare']);
});
it('a stay that stands keeps the spare day it checks out on', () => {
const p = plan('2026-10-01', '2026-10-09', [{ start_day_id: 9, end_day_id: 11 }]);
expect(gone(p)).toEqual(['10:overflow']);
expect(ids(p).slice(-2)).toEqual([11, 12]);
});
it('reads the order from the day numbers, whatever order the rows come in', () => {
const shuffled = [tenDays[4]!, tenDays[11]!, tenDays[0]!, tenDays[9]!, tenDays[10]!];
const p = planDayGrid({ days: shuffled, stays: [], startDate: '2026-11-01', endDate: '2026-11-02' });
expect(p.rows).toEqual([
{ id: 1, date: '2026-11-01' },
{ id: 5, date: '2026-11-02' },
{ id: 12, date: null },
]);
expect(gone(p)).toEqual(['10:overflow', '11:spare']);
});
describe('without dates', () => {
const undated = (n: number, planned: number[] = [], stays: DayGridStay[] = [], dayCount?: number) =>
planDayGrid({
days: Array.from({ length: n }, (_, i) => ({
id: i + 1,
day_number: i + 1,
date: i < 2 ? '2026-10-01' : null,
hasPlanItems: planned.includes(i + 1),
})),
stays,
startDate: null,
endDate: null,
dayCount,
});
it('trims to the count from the back, and only empty days', () => {
const p = undated(6, [5, 6], [{ start_day_id: 3, end_day_id: 3 }], 2);
expect(p.removed.map((r) => r.id)).toEqual([4, 2, 1]);
expect(p.removed.every((r) => r.reason === 'spare')).toBe(true);
expect(p.rows).toEqual([
{ id: 3, date: null },
{ id: 5, date: null },
{ id: 6, date: null },
]);
expect(p.removed.at(-1)).toMatchObject({ id: 1, date: '2026-10-01' });
});
it('adds empty days up to the count, and seven for a trip that has none', () => {
expect(undated(2, [], [], 4).rows).toEqual([
{ id: 1, date: null },
{ id: 2, date: null },
{ id: null, date: null },
{ id: null, date: null },
]);
expect(undated(0).rows).toHaveLength(7);
expect(undated(3, [], [], MAX_TRIP_DAYS + 1).rows).toHaveLength(MAX_TRIP_DAYS);
});
});
});
+153
View File
@@ -65,3 +65,156 @@ export function planDatedAppend(
const date = dayAfterLatest(trip.end_date, dayDates);
return { date, fits: tripSpanDays(trip.start_date, date) <= MAX_TRIP_DAYS };
}
/** The dates and day count an update leaves a trip with, and whether its day rows are rebuilt. */
export interface DayGridRange {
newStart: string | null | undefined;
newEnd: string | null | undefined;
/** A requested day count, held to 1..MAX_TRIP_DAYS; undefined when none came. */
dayCount: number | undefined;
/** True when a date moves or a day count arrives: the day rows are laid out anew. */
regenerate: boolean;
}
/**
* Where an update takes a trip's range. A field left out keeps the stored
* value, an explicit null clears it. The server rebuilds the day rows exactly
* when `regenerate` is true, and the trip dialog asks the same question before
* it saves, so a warning about lost days never fires for a save that loses none.
* Refusing a bad range stays with the server.
*/
export function resolveDayGridRange(
prev: { start_date?: string | null; end_date?: string | null },
data: { start_date?: string | null; end_date?: string | null; day_count?: number | null },
): DayGridRange {
const newStart = data.start_date !== undefined ? data.start_date : prev.start_date;
const newEnd = data.end_date !== undefined ? data.end_date : prev.end_date;
const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
const regenerate = newStart !== prev.start_date || newEnd !== prev.end_date || dayCount !== undefined;
return { newStart, newEnd, dayCount, regenerate };
}
/** A day row as the layout reads it. */
export interface DayGridDay {
id: number;
day_number: number;
date: string | null | undefined;
/** Places planned on the day, or notes on it. A day title or text does not count. */
hasPlanItems: boolean;
}
/** A stay by the two days it checks in and out on. */
export interface DayGridStay {
start_day_id: number;
end_day_id: number;
}
/**
* Why a day goes. `overflow`: a dated day past the new range, removed with
* everything on it. `spare`: a day with nothing planned that the new layout has
* no place for.
*/
export type DayGridRemovalReason = 'overflow' | 'spare';
export interface DayGridRemoval {
id: number;
/** Where the day stood before, and the date it had. */
day_number: number;
date: string | null;
reason: DayGridRemovalReason;
}
/** One row of the new layout: an existing day by its id, or a new one (null). */
export interface DayGridRow {
id: number | null;
date: string | null;
}
/** The day rows a trip ends up with, in order (index + 1 is the day number), and the days that go. */
export interface DayGridPlan {
rows: DayGridRow[];
removed: DayGridRemoval[];
}
const byDayNumber = (a: DayGridDay, b: DayGridDay): number => a.day_number - b.day_number;
const removal = (day: DayGridDay, reason: DayGridRemovalReason): DayGridRemoval => ({
id: day.id,
day_number: day.day_number,
date: day.date || null,
reason,
});
/** Nothing planned on the day and no stay checking in or out on it. */
const isEmptyDay = (day: DayGridDay, stays: readonly DayGridStay[]): boolean =>
!day.hasPlanItems && !stays.some((s) => s.start_day_id === day.id || s.end_day_id === day.id);
/**
* The day rows a trip gets for a range, the rule the server writes by
* (TripsService.generateDays carries this plan out) and the trip dialog warns
* by before it saves.
*
* With dates, plans follow their position: the n-th dated day takes the n-th
* date of the new range, so shortening a trip always takes the last dated days,
* with everything on them, even when it was the start that moved. Days without
* a date fill any dates left over, then new empty days do. Days without a date
* that are still over are dropped when empty and kept, undated, at the end when
* not. A stay on a dropped dated day goes with that day, so it no longer keeps a
* spare day it touched.
*
* Without dates every day loses its date and the trip is held to `dayCount`
* days (its current count when none is given): short of it, empty days are
* added at the end; over it, empty days go, the highest numbers first. A day
* with something planned on it is never dropped, even if that leaves the trip
* over the count.
*/
export function planDayGrid(input: {
days: readonly DayGridDay[];
stays: readonly DayGridStay[];
startDate: string | null | undefined;
endDate: string | null | undefined;
dayCount?: number;
}): DayGridPlan {
const { days, stays, startDate, endDate, dayCount } = input;
if (!startDate || !endDate) {
const ordered = [...days].sort(byDayNumber);
const target = Math.min(Math.max(dayCount ?? (ordered.length || 7), 1), MAX_TRIP_DAYS);
const surplus = ordered.length - target;
const removed =
surplus > 0
? ordered
.filter((day) => isEmptyDay(day, stays))
.reverse()
.slice(0, surplus)
.map((day) => removal(day, 'spare'))
: [];
const gone = new Set(removed.map((r) => r.id));
const rows: DayGridRow[] = ordered.filter((day) => !gone.has(day.id)).map((day) => ({ id: day.id, date: null }));
for (let i = 0; i < -surplus; i++) rows.push({ id: null, date: null });
return { rows, removed };
}
const dated = days.filter((day) => day.date).sort(byDayNumber);
const undated = days.filter((day) => !day.date).sort(byDayNumber);
const span = tripSpanDays(startDate, endDate);
const rows: DayGridRow[] = [];
let used = 0;
for (let i = 0; i < span; i++) {
const date = addIsoDays(startDate, i);
if (i < dated.length) rows.push({ id: dated[i]!.id, date });
else if (used < undated.length) rows.push({ id: undated[used++]!.id, date });
else rows.push({ id: null, date });
}
const overflow = dated.slice(Math.max(span, 0));
const gone = new Set(overflow.map((day) => day.id));
const standing = stays.filter((s) => !gone.has(s.start_day_id) && !gone.has(s.end_day_id));
const removed = overflow.map((day) => removal(day, 'overflow'));
for (const day of undated.slice(used)) {
if (isEmptyDay(day, standing)) removed.push(removal(day, 'spare'));
else rows.push({ id: day.id, date: null });
}
return { rows, removed };
}
+13
View File
@@ -109,4 +109,17 @@ Each row of the **Reorder days** dialog has a delete button; on a phone it sits
A trip always keeps at least one day, and deleting needs a connection. There is no undo, which is why the dialog spells out the consequences first.
## Shortening a trip
New dates lay the days of a trip out again. Plans follow their position: the first day of the plan takes the new start date, the second the date after it, and so on. When the new dates hold fewer days than the plan, the last days go, also when it was the start that moved. Empty days without a date go as well.
Before such a save, the trip dialog (on a phone, the trip sheet) asks and lists the days that go, then what is on them:
- **Planned places** stay in the place list of the trip.
- **Notes**, day titles and day descriptions are deleted.
- **Bookings** on those days stay under Bookings. With **Keep bookings on their dates**, a booking whose date is still part of the trip goes back onto that day; with **Shift everything** it stays without a day.
- **A stay that checks in or out on one of those days** is removed as a whole, also for nights that are still part of the trip. Its booking and the expense of that booking stay under Bookings. The list shows the stay in red.
When the start moved as well, the same step also asks how bookings follow the new dates. Nothing is asked when the days that go are empty, and there is no undo.
**See also:** [Places-and-Search](Places-and-Search) · [Map-Features](Map-Features) · [Route-Optimization](Route-Optimization) · [Weather-Forecasts](Weather-Forecasts) · [Reservations-and-Bookings](Reservations-and-Bookings)
+1 -1
View File
@@ -34,7 +34,7 @@ Requires `trips:read` or `trips:write` scope.
|---|---|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a trip with title, dates, and currency. Days are auto-generated from the date range. Without a currency the trip takes your display currency (Settings → General, or the admin's User Defaults preset), and EUR when that is left on *Trip currency*; see [Currencies](Currencies). |
| `update_trip` | Update a trip's title, description, dates, or currency. |
| `update_trip` | Update a trip's title, description, dates, or currency. Shortening a dated trip removes its last days with what is on them, and the result then lists them in `removed_days`. |
| `search_cover_images` | Search Unsplash for candidate cover photos and return their URLs, thumbnails and photographer credits. Nothing is saved; pass the chosen photo's URL to `update_trip` as `cover_image`. |
| `delete_trip` | Delete a trip. Owner only. Requires `trips:delete`. |
| `list_trip_members` | List the owner and all collaborators of a trip. |