mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]])
|
||||
})
|
||||
})
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user