mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier. The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle. On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place } from '../types'
|
||||
import type { Place, Trip } from '../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import {
|
||||
@@ -438,7 +438,10 @@ export const daysApi = {
|
||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
// Whole-day default route mode (#1281); per-segment leg modes override it.
|
||||
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||
// Answers with the trip in list shape: its day count changed, and its end
|
||||
// date when the day took the last date along.
|
||||
delete: (tripId: number | string, dayId: number | string): Promise<{ success: boolean; trip?: Trip }> =>
|
||||
apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ interface DayPlanSidebarProps {
|
||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
onAddDay?: (position?: number) => void
|
||||
/** Asks to delete a day from the reorder dialog; the planner owns the question. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
/** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */
|
||||
onUpdateDayTitle: (dayId: number, title: string) => void
|
||||
/** The day route is computed by the planner page itself; kept for the existing call sites. */
|
||||
@@ -146,7 +148,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||
onReorder, onReorderDays, onAddDay,
|
||||
onReorder, onReorderDays, onAddDay, onDeleteDay,
|
||||
onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
@@ -1129,6 +1131,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
onDeleteDay,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
@@ -1331,6 +1334,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
onDeleteDay,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
@@ -1590,6 +1594,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
canManageShare={canManageShare}
|
||||
onReorderDays={onReorderDays}
|
||||
onAddDay={onAddDay}
|
||||
onDeleteDay={onDeleteDay}
|
||||
/>
|
||||
|
||||
{/* Tagesliste */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-024
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
@@ -233,6 +233,16 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(onAddDay).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-024: the popup hands a delete request for its day to the planner', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 }), buildDay({ id: 11, title: 'Day 2', day_number: 2 })]
|
||||
const onDeleteDay = vi.fn((_id: number) => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn(), onDeleteDay })} />)
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
|
||||
await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1])
|
||||
expect(onDeleteDay).toHaveBeenCalledWith(11)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-021: the reorder button is hidden without edit rights or without days', () => {
|
||||
const days = [buildDay({ id: 10 })]
|
||||
const { rerender } = render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: false, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
|
||||
|
||||
@@ -37,6 +37,8 @@ interface DayPlanSidebarToolbarProps {
|
||||
canManageShare?: boolean
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
onAddDay?: (position?: number) => void
|
||||
/** Asks to delete a day from the reorder dialog; without it the dialog has no delete buttons. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
}
|
||||
|
||||
export function DayPlanSidebarToolbar({
|
||||
@@ -44,7 +46,7 @@ export function DayPlanSidebarToolbar({
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay, onDeleteDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
@@ -197,6 +199,7 @@ export function DayPlanSidebarToolbar({
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
onDeleteDay={onDeleteDay}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-012
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-016
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { buildDay } from '../../../tests/helpers/factories'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import { setForcedOffline } from '../../sync/networkMode'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
// The component takes `t` as a prop, so returning the key keeps assertions exact.
|
||||
@@ -137,4 +138,47 @@ describe('DayReorderPopup', () => {
|
||||
await user.click(screen.getByText('common.close'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('deleting a day', () => {
|
||||
afterEach(() => setForcedOffline(false))
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-013: each row asks to delete its own day, and only asks', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDeleteDay = vi.fn()
|
||||
const onReorder = vi.fn()
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay, onReorder })} />)
|
||||
const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' })
|
||||
expect(buttons).toHaveLength(3)
|
||||
await user.click(buttons[1])
|
||||
expect(onDeleteDay).toHaveBeenCalledWith(7)
|
||||
expect(onReorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-014: without a delete handler the rows carry no delete button', () => {
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays() })} />)
|
||||
expect(screen.queryByRole('button', { name: 'dayplan.deleteDay' })).not.toBeInTheDocument()
|
||||
// The label is still the row's second span, next to its position badge.
|
||||
expect(rows()[0].querySelectorAll('span')[1].textContent).toBe('Paris')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-015: the only day of a trip cannot be deleted', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDeleteDay = vi.fn()
|
||||
render(<DayReorderPopup {...makeProps({ days: [buildDay({ id: 3, title: 'Paris', day_number: 1 })], onDeleteDay })} />)
|
||||
const button = screen.getByRole('button', { name: 'dayplan.deleteDay' })
|
||||
expect(button).toBeDisabled()
|
||||
await user.click(button)
|
||||
expect(onDeleteDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-016: offline every delete button is off, and hovering says why', async () => {
|
||||
setForcedOffline(true)
|
||||
const user = userEvent.setup()
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn() })} />)
|
||||
const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' })
|
||||
expect(buttons.every(b => (b as HTMLButtonElement).disabled)).toBe(true)
|
||||
await user.hover(buttons[0].parentElement as HTMLElement)
|
||||
expect(await screen.findByText('dayplan.daysOffline')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { dayLabel } from '../../utils/dayLabel'
|
||||
import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
interface DayReorderPopupProps {
|
||||
@@ -10,29 +14,25 @@ interface DayReorderPopupProps {
|
||||
locale: string
|
||||
onReorder: (orderedIds: number[]) => void
|
||||
onAddDay: () => void
|
||||
/** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for moving whole days around: drag a row by its grip or use the up/down
|
||||
* arrows, and add a day at the end. Day headers stay untouched — this is the
|
||||
* single surface for ordering. Reorders are applied optimistically by the store,
|
||||
* so the list reflects each move immediately.
|
||||
* arrows, add a day at the end, or delete one. Day headers stay untouched, so
|
||||
* this is the single surface for ordering. Reorders are applied optimistically
|
||||
* by the store, so the list reflects each move immediately. A delete only asks:
|
||||
* the planner shows what goes with the day before anything happens.
|
||||
*/
|
||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onDeleteDay, onClose }: DayReorderPopupProps) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null)
|
||||
const { offline } = useNetworkMode()
|
||||
|
||||
const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
|
||||
const label = (day: Day, index: number) => {
|
||||
if (day.title) return day.title
|
||||
if (day.date) {
|
||||
const d = new Date(day.date + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
return t('dayplan.dayN', { n: index + 1 })
|
||||
}
|
||||
const deleteBlocked = deleteDayBlockedReason(ordered.length, offline, t)
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= ordered.length || from === to) return
|
||||
@@ -120,7 +120,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
{index + 1}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label(day, index)}
|
||||
{dayLabel(day, index, t, locale)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -140,6 +140,24 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
>
|
||||
<ArrowDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{onDeleteDay && (
|
||||
<>
|
||||
<span aria-hidden="true" className="mx-0.5 h-5 w-px flex-shrink-0 bg-edge-faint" />
|
||||
<Tooltip label={deleteBlocked ?? t('dayplan.deleteDay')} placement="left">
|
||||
<span className="inline-flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteDay(day.id)}
|
||||
disabled={!!deleteBlocked}
|
||||
aria-label={t('dayplan.deleteDay')}
|
||||
className="grid h-7 w-7 place-items-center rounded-[7px] border border-edge-faint text-content-muted transition-colors hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-content-muted"
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import Modal from './Modal';
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
const onClose = vi.fn();
|
||||
@@ -91,6 +92,44 @@ describe('ConfirmDialog', () => {
|
||||
process.off('unhandledRejection', unhandled);
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-010: extra content renders under the message, and the card widens for it', () => {
|
||||
render(
|
||||
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Delete it?">
|
||||
<ul aria-label="consequences"><li>Stay at Harbour Hotel</li></ul>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
const list = screen.getByRole('list', { name: 'consequences' });
|
||||
expect(list).toHaveTextContent('Stay at Harbour Hotel');
|
||||
expect(screen.getByText('Delete it?').compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(document.querySelector('.max-w-md')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-011: Escape takes back the question only, not the dialog it was asked from', () => {
|
||||
const closeModal = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<Modal isOpen={true} onClose={closeModal} title="Reorder days">rows</Modal>
|
||||
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Delete it?" />
|
||||
</>
|
||||
);
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
expect(closeModal).not.toHaveBeenCalled();
|
||||
// Other keys pass through untouched.
|
||||
fireEvent.keyDown(document.body, { key: 'Enter' });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-012: the danger look comes from the danger tokens, the plain one from the accent', () => {
|
||||
const { unmount } = render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} />);
|
||||
expect(screen.getByRole('button', { name: /delete/i })).toHaveClass('bg-danger');
|
||||
expect(document.querySelector('.bg-danger-soft')).not.toBeNull();
|
||||
unmount();
|
||||
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} danger={false} confirmLabel="Keep" />);
|
||||
expect(screen.getByRole('button', { name: 'Keep' })).toHaveClass('bg-accent', 'text-accent-text');
|
||||
expect(document.querySelector('.bg-danger-soft')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-008: clicking backdrop calls onClose', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="msg" />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback } from 'react'
|
||||
import React, { useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -12,6 +12,8 @@ interface ConfirmDialogProps {
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
/** Extra content under the message, such as a list of what the action takes with it. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
// Callers commonly pass an async handler that reports its own failure and then
|
||||
@@ -33,18 +35,23 @@ export default function ConfirmDialog({
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
danger = true,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Captured and stopped here: the question often opens over another dialog
|
||||
// (a Modal) that closes on Escape too, and one key press should only take
|
||||
// back the question, not the dialog it was asked from.
|
||||
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key !== 'Escape') return
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleEsc)
|
||||
if (!isOpen) return
|
||||
document.addEventListener('keydown', handleEsc, true)
|
||||
return () => document.removeEventListener('keydown', handleEsc, true)
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -60,22 +67,23 @@ export default function ConfirmDialog({
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6 bg-surface-card"
|
||||
className={`trek-modal-enter rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{danger && (
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-danger-soft flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-danger" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-content">
|
||||
{title || t('common.confirm')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-content-secondary">
|
||||
{message}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,8 +96,8 @@ export default function ConfirmDialog({
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { runConfirm(onConfirm); onClose() }}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white ${
|
||||
danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-opacity hover:opacity-90 ${
|
||||
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel || t('common.delete')}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// FE-COMP-DAYIMPACT-001 to FE-COMP-DAYIMPACT-003
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { BedDouble, MapPin } from 'lucide-react'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import DayImpactList from './DayImpactList'
|
||||
import MDayImpactList from '../../mobile/components/MDayImpactList'
|
||||
import type { ImpactLine } from '../../utils/dayImpactLines'
|
||||
|
||||
const lines: ImpactLine[] = [
|
||||
{ key: 'stay-9', icon: BedDouble, tone: 'danger', text: 'Stay at Harbour Hotel', hint: 'Cancelled with its booking.' },
|
||||
{ key: 'places', icon: MapPin, tone: 'neutral', text: 'Planned places: 3' },
|
||||
]
|
||||
|
||||
describe('DayImpactList', () => {
|
||||
it('FE-COMP-DAYIMPACT-001: one row per line, text and hint, named for assistive tech', () => {
|
||||
render(<DayImpactList lines={lines} label="Delete Tue, Oct 13?" />)
|
||||
const list = screen.getByRole('list', { name: 'Delete Tue, Oct 13?' })
|
||||
const rows = screen.getAllByRole('listitem')
|
||||
expect(list).toContainElement(rows[0])
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toHaveTextContent('Stay at Harbour Hotel')
|
||||
expect(rows[0]).toHaveTextContent('Cancelled with its booking.')
|
||||
expect(rows[1]).toHaveTextContent('Planned places: 3')
|
||||
})
|
||||
|
||||
it('FE-COMP-DAYIMPACT-002: the tone reaches the row: money in the danger tokens, the rest in the content tokens', () => {
|
||||
render(<DayImpactList lines={lines} />)
|
||||
const [stay, places] = screen.getAllByRole('listitem')
|
||||
expect(stay).toHaveAttribute('data-tone', 'danger')
|
||||
expect(screen.getByText('Stay at Harbour Hotel')).toHaveClass('text-danger')
|
||||
expect(stay.querySelector('span')).toHaveClass('bg-danger-soft')
|
||||
expect(screen.getByText('Planned places: 3')).toHaveClass('text-content')
|
||||
expect(places.querySelectorAll('p')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-DAYIMPACT-003: renders nothing without lines, and the phone skin draws the same rows in its own tokens', () => {
|
||||
const { container } = render(<DayImpactList lines={[]} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
render(<MDayImpactList lines={lines} label="phone" />)
|
||||
const phone = screen.getByRole('list', { name: 'phone' })
|
||||
expect(phone.className).toContain('var(--m-inner)')
|
||||
const rows = phone.querySelectorAll('li')
|
||||
expect(rows).toHaveLength(2)
|
||||
// Hairlines between rows, not above the first.
|
||||
expect(rows[0].className).not.toContain('border-t')
|
||||
expect(rows[1].className).toContain('border-t')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ImpactLine, ImpactTone } from '../../utils/dayImpactLines'
|
||||
|
||||
/** The class names one shell draws the list with. */
|
||||
export interface ImpactListSkin {
|
||||
list: string
|
||||
row: string
|
||||
/** Between two rows; the desktop card divides them itself. */
|
||||
divider: string
|
||||
chip: string
|
||||
iconSize: number
|
||||
text: string
|
||||
hint: string
|
||||
chipTone: Record<ImpactTone, string>
|
||||
textTone: Record<ImpactTone, string>
|
||||
}
|
||||
|
||||
const DESKTOP: ImpactListSkin = {
|
||||
list: 'mt-4 overflow-hidden rounded-xl border border-edge-faint bg-surface-secondary divide-y divide-edge-faint',
|
||||
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',
|
||||
iconSize: 15,
|
||||
text: 'text-body font-medium leading-snug',
|
||||
hint: 'mt-0.5 text-caption text-content-muted',
|
||||
chipTone: {
|
||||
neutral: 'bg-surface text-content-muted',
|
||||
muted: 'bg-surface text-content-faint',
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
danger: 'bg-danger-soft text-danger',
|
||||
},
|
||||
textTone: { neutral: 'text-content', muted: 'text-content-muted', warning: 'text-content', danger: 'text-danger' },
|
||||
}
|
||||
|
||||
interface DayImpactListProps {
|
||||
lines: ImpactLine[]
|
||||
/** 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. */
|
||||
skin?: ImpactListSkin
|
||||
}
|
||||
|
||||
/**
|
||||
* What goes with the days being removed, one row per kind of content: an icon
|
||||
* chip in the row's tone, the count, and a caption saying what happens to it.
|
||||
* The rows come ready made from utils/dayImpactLines, and the look comes from a
|
||||
* 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) {
|
||||
if (lines.length === 0) return null
|
||||
return (
|
||||
<ul aria-label={label} className={skin.list}>
|
||||
{lines.map(({ key, icon: Icon, text, hint, tone }, i) => (
|
||||
<li key={key} data-tone={tone} className={`${skin.row} ${i > 0 ? skin.divider : ''}`.trim()}>
|
||||
<span className={`${skin.chip} ${skin.chipTone[tone]}`}>
|
||||
<Icon size={skin.iconSize} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`${skin.text} ${skin.textTone[tone]}`}>{text}</p>
|
||||
{hint && <p className={skin.hint}>{hint}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import DayImpactList, { type ImpactListSkin } from '../../components/shared/DayImpactList'
|
||||
import type { ImpactLine } from '../../utils/dayImpactLines'
|
||||
|
||||
/** The mobile tokens for the same rows: an inset card with hairlines between rows. */
|
||||
const PHONE: ImpactListSkin = {
|
||||
list: 'mt-3 overflow-hidden rounded-[13px] border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)]',
|
||||
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',
|
||||
iconSize: 13,
|
||||
text: 'text-[0.78125rem] font-semibold leading-snug',
|
||||
hint: 'mt-[2px] text-[0.6875rem] leading-snug text-m-muted',
|
||||
chipTone: {
|
||||
neutral: 'bg-[color:var(--m-ic)] text-m-muted',
|
||||
muted: 'bg-[color:var(--m-ic)] text-m-faint',
|
||||
warning: 'bg-[color:color-mix(in_srgb,var(--m-st-pending)_14%,transparent)] text-[color:var(--m-st-pending)]',
|
||||
danger: 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_12%,transparent)] text-[color:var(--m-st-danger)]',
|
||||
},
|
||||
textTone: {
|
||||
neutral: 'text-m-ink',
|
||||
muted: 'text-m-muted',
|
||||
warning: 'text-m-ink',
|
||||
danger: 'text-[color:var(--m-st-danger)]',
|
||||
},
|
||||
}
|
||||
|
||||
/** 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} />
|
||||
}
|
||||
@@ -1,33 +1,24 @@
|
||||
import { CalendarRange, Plus } from 'lucide-react'
|
||||
import { CalendarRange, Plus, Trash2 } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { ReorderStack } from '../plan/MPlanTimelineRows'
|
||||
import { INNER_CLS, TileHeader } from './MTripSheetUi'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { dayLabel } from '../../../../utils/dayLabel'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
import type { Day } from '../../../../types'
|
||||
|
||||
/**
|
||||
* Day management sheet ('days'): move whole days up/down and append a new day —
|
||||
* the mobile counterpart of the desktop DayReorderPopup, button-based like the
|
||||
* rest of the touch reordering (#1432). A day's places, notes and bookings
|
||||
* move with it (store handles that optimistically).
|
||||
* Day management sheet ('days'): move whole days up/down, append a new day or
|
||||
* delete one, the mobile counterpart of the desktop DayReorderPopup and
|
||||
* button-based like the rest of the touch reordering (#1432). A day's places,
|
||||
* notes and bookings move with it (store handles that optimistically). Delete
|
||||
* only asks; the confirm sheet with what goes with the day is MTripSheets'.
|
||||
*/
|
||||
export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const open = shell.sheet?.id === 'days'
|
||||
const canEditDays = planner.can('day_edit', planner.trip)
|
||||
const ordered = [...planner.days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
|
||||
const label = (day: Day, index: number): string => {
|
||||
if (day.title) return day.title
|
||||
if (day.date) {
|
||||
const d = new Date(`${day.date.slice(0, 10)}T00:00:00`)
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
}
|
||||
return t('planner.dayN', { n: day.day_number ?? index + 1 })
|
||||
}
|
||||
const deleteBlocked = planner.deleteDayBlocked
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= ordered.length || from === to) return
|
||||
@@ -57,7 +48,7 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
|
||||
<span className="flex h-6 w-6 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] font-geist text-[0.65625rem] font-bold text-m-muted">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{label(day, i)}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{dayLabel(day, i, t, locale)}</span>
|
||||
{canEditDays && (
|
||||
<ReorderStack
|
||||
onUp={() => move(i, i - 1)}
|
||||
@@ -67,6 +58,18 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{canEditDays && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => planner.handleDeleteDay(day.id)}
|
||||
disabled={!!deleteBlocked}
|
||||
aria-label={t('dayplan.deleteDay')}
|
||||
title={deleteBlocked ?? undefined}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)] disabled:text-m-faint disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -80,6 +83,9 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
|
||||
{t('dayplan.addDay')}
|
||||
</button>
|
||||
)}
|
||||
{canEditDays && deleteBlocked && (
|
||||
<p className="mt-[10px] text-center text-[0.6875rem] text-m-muted">{deleteBlocked}</p>
|
||||
)}
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAuthStore } from '../../../../store/authStore'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import MDayImpactList from '../../../components/MDayImpactList'
|
||||
import MDaySheet from './MDaySheet'
|
||||
import MDaysSheet from './MDaysSheet'
|
||||
import MAccommodationSheet from './MAccommodationSheet'
|
||||
@@ -201,6 +202,22 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
planner.setDeletePlaceId(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Delete-day confirm behind the days sheet's delete buttons. Mounted
|
||||
last, so it opens over that sheet; the list of what goes with the day
|
||||
comes ready made from the planner, the same one the desktop shows. */}
|
||||
<MConfirmSheet
|
||||
open={planner.deleteDayId != null}
|
||||
onClose={() => planner.setDeleteDayId(null)}
|
||||
title={planner.deleteDayTitle}
|
||||
message={t('dayplan.deleteDayBody')}
|
||||
confirmLabel={t('dayplan.deleteDay')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
onConfirm={() => { void planner.confirmDeleteDay() }}
|
||||
>
|
||||
<MDayImpactList lines={planner.deleteDayLines} label={planner.deleteDayTitle} />
|
||||
</MConfirmSheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,10 +150,21 @@ vi.mock('../components/Planner/ReservationModal', () => ({
|
||||
}));
|
||||
|
||||
const capturedConfirmDialogProps: { current: Record<string, any> } = { current: {} };
|
||||
// Every dialog the page renders, by title: the page holds several, and the last
|
||||
// one rendered is not always the one a case is about. An open one draws its
|
||||
// extra content, so a case can read the list it carries.
|
||||
interface ConfirmDialogStub {
|
||||
isOpen?: boolean
|
||||
message?: string
|
||||
confirmLabel?: string
|
||||
onConfirm?: () => unknown
|
||||
}
|
||||
const capturedConfirmDialogsByTitle: { current: Record<string, ConfirmDialogStub> } = { current: {} };
|
||||
vi.mock('../components/shared/ConfirmDialog', () => ({
|
||||
default: (props: Record<string, any>) => {
|
||||
capturedConfirmDialogProps.current = props;
|
||||
return null;
|
||||
capturedConfirmDialogsByTitle.current[String(props.title)] = props as ConfirmDialogStub;
|
||||
return props.isOpen ? React.createElement('div', { 'data-testid': 'confirm-dialog' }, props.children) : null;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -276,6 +287,7 @@ beforeEach(() => {
|
||||
capturedPlaceFormModalProps.current = {};
|
||||
capturedReservationModalProps.current = {};
|
||||
capturedConfirmDialogProps.current = {};
|
||||
capturedConfirmDialogsByTitle.current = {};
|
||||
capturedDayDetailPanelProps.current = {};
|
||||
capturedTripFormModalProps.current = {};
|
||||
capturedTripMembersModalProps.current = {};
|
||||
@@ -1740,6 +1752,47 @@ describe('TripPlannerPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-053: Deleting a day from the reorder dialog', () => {
|
||||
it('asks with the list of what goes with the day, then deletes through the store', async () => {
|
||||
vi.useFakeTimers();
|
||||
seedTripStore({ id: 42 });
|
||||
const deleteDay = vi.fn().mockResolvedValue(undefined);
|
||||
const harbour = buildDay({ id: 902, trip_id: 42, day_number: 2, date: null, title: 'Harbour day' });
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 901, trip_id: 42, day_number: 1, date: null }), harbour],
|
||||
dayNotes: {},
|
||||
deleteDay,
|
||||
});
|
||||
|
||||
renderPlannerPage(42);
|
||||
act(() => { vi.runAllTimers(); });
|
||||
vi.useRealTimers();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The sidebar hands the reorder dialog its delete action; nothing opens until it is used.
|
||||
expect(typeof capturedDayPlanSidebarProps.current.onDeleteDay).toBe('function');
|
||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => { capturedDayPlanSidebarProps.current.onDeleteDay(harbour.id); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedConfirmDialogsByTitle.current['Delete Harbour day?']?.isOpen).toBe(true);
|
||||
});
|
||||
const dialog = capturedConfirmDialogsByTitle.current['Delete Harbour day?'];
|
||||
expect(dialog.message).toBe('The day is removed from the trip. This cannot be undone.');
|
||||
expect(dialog.confirmLabel).toBe('Delete day');
|
||||
expect(screen.getByRole('list', { name: 'Delete Harbour day?' })).toHaveTextContent('Day titles and descriptions: 1');
|
||||
|
||||
await act(async () => { await dialog.onConfirm?.(); });
|
||||
expect(deleteDay).toHaveBeenCalledWith(42, harbour.id);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-052: Road trip mode opens a booking the way the day plan does (#2428)', () => {
|
||||
// The rail is mounted only with the addon on and the mode on for this trip; the
|
||||
// road-trip hooks then read their own endpoints, answered empty here.
|
||||
|
||||
@@ -42,6 +42,7 @@ import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsAp
|
||||
import { accommodationRepo } from '../repo/accommodationRepo'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import DayImpactList from '../components/shared/DayImpactList'
|
||||
import { useTripWebSocket } from '../hooks/useTripWebSocket'
|
||||
import { useRouteCalculation } from '../hooks/useRouteCalculation'
|
||||
import { usePlaceSelection } from '../hooks/usePlaceSelection'
|
||||
@@ -304,6 +305,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
deleteDayId, setDeleteDayId, deleteDayTitle, deleteDayLines, handleDeleteDay, confirmDeleteDay,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
mapTileUrl, fontStyle, splashDone,
|
||||
@@ -656,6 +658,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onReorder={handleReorder}
|
||||
onReorderDays={handleReorderDays}
|
||||
onAddDay={handleAddDay}
|
||||
onDeleteDay={handleDeleteDay}
|
||||
onUpdateDayTitle={handleUpdateDayTitle}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onMoveToDay={handleMoveToDay}
|
||||
@@ -948,7 +951,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onDeleteDay={handleDeleteDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
@@ -1176,6 +1179,18 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
? `${t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} ${deletePlacesNote}`
|
||||
: t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })}
|
||||
/>
|
||||
{/* Deleting a day from the reorder dialog: the question lists what goes
|
||||
with the day, and a booked stay in it shows in the danger tone. */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteDayId != null}
|
||||
onClose={() => setDeleteDayId(null)}
|
||||
onConfirm={confirmDeleteDay}
|
||||
title={deleteDayTitle}
|
||||
message={t('dayplan.deleteDayBody')}
|
||||
confirmLabel={t('dayplan.deleteDay')}
|
||||
>
|
||||
<DayImpactList lines={deleteDayLines} label={deleteDayTitle} />
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
isOpen={!!stayRelease}
|
||||
onClose={() => setStayRelease(null)}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { dayDeleteImpact, deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
|
||||
import { deleteDayLines as buildDeleteDayLines, type ImpactLine } from '../../utils/dayImpactLines'
|
||||
import { dayLabel } from '../../utils/dayLabel'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import type { Accommodation, Day, Place, Reservation, Trip } from '../../types'
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
interface DayDeleteOptions {
|
||||
tripId: number
|
||||
trip: Trip | null
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
reservations: Reservation[]
|
||||
accommodations: Accommodation[]
|
||||
canEditDays: boolean
|
||||
t: Translate
|
||||
locale: string
|
||||
toast: { success: (message: string) => unknown; error: (message: string) => unknown }
|
||||
/** After a delete went through: the stays and the route of the selected day are the planner's to reload. */
|
||||
onDeleted: () => void
|
||||
}
|
||||
|
||||
export interface DayDelete {
|
||||
/** The day the question is asked about, or null while no question is open. */
|
||||
deleteDayId: number | null
|
||||
setDeleteDayId: (dayId: number | null) => void
|
||||
/** "Delete Tue, Oct 13?", the day named the way the reorder list names it. */
|
||||
deleteDayTitle: string
|
||||
/** What goes with the day, ready for DayImpactList and its phone skin. */
|
||||
deleteDayLines: ImpactLine[]
|
||||
/** Why no day can be deleted right now (the last day, offline), or null. */
|
||||
deleteDayBlocked: string | null
|
||||
handleDeleteDay: (dayId: number) => void
|
||||
confirmDeleteDay: () => Promise<void>
|
||||
}
|
||||
|
||||
const byNumber = (a: Day, b: Day): number => (a.day_number ?? 0) - (b.day_number ?? 0)
|
||||
|
||||
/**
|
||||
* Deleting a day from the reorder dialog, for both shells: which day is asked
|
||||
* about, the question's title and the list of what goes with the day, and the
|
||||
* delete itself. The desktop dialog and the phone sheet only render what this
|
||||
* returns, which keeps the logic in one place for the duplication gate.
|
||||
*
|
||||
* There is no undo: the server cancels stays and moves dates in one go, and
|
||||
* nothing puts all of that back. The question says so instead.
|
||||
*/
|
||||
export function useDayDelete(options: DayDeleteOptions): DayDelete {
|
||||
const { tripId, trip, days, places, reservations, accommodations, canEditDays, t, locale, toast, onDeleted } = options
|
||||
const { offline } = useNetworkMode()
|
||||
const assignments = useTripStore(s => s.assignments)
|
||||
const dayNotes = useTripStore(s => s.dayNotes)
|
||||
const [deleteDayId, setDeleteDayId] = useState<number | null>(null)
|
||||
|
||||
const deleteDayBlocked = deleteDayBlockedReason(days.length, offline, t)
|
||||
|
||||
const ordered = useMemo(() => [...days].sort(byNumber), [days])
|
||||
const index = deleteDayId == null ? -1 : ordered.findIndex(d => d.id === deleteDayId)
|
||||
const target = index < 0 ? null : ordered[index]
|
||||
|
||||
const deleteDayTitle = useMemo(
|
||||
() => (target ? t('dayplan.deleteDayTitle', { day: dayLabel(target, index, t, locale) }) : ''),
|
||||
[target, index, t, locale],
|
||||
)
|
||||
|
||||
const deleteDayLines = useMemo(() => {
|
||||
if (!target) return []
|
||||
const impact = dayDeleteImpact(target, days, { assignments, dayNotes, reservations, accommodations, places }, trip)
|
||||
return buildDeleteDayLines(impact, t, iso => formatDate(iso, locale) ?? iso)
|
||||
}, [target, days, assignments, dayNotes, reservations, accommodations, places, trip, t, locale])
|
||||
|
||||
const handleDeleteDay = useCallback((dayId: number) => {
|
||||
if (!canEditDays || deleteDayBlocked) return
|
||||
setDeleteDayId(dayId)
|
||||
}, [canEditDays, deleteDayBlocked])
|
||||
|
||||
const confirmDeleteDay = useCallback(async () => {
|
||||
if (deleteDayId == null) return
|
||||
const dayId = deleteDayId
|
||||
setDeleteDayId(null)
|
||||
try {
|
||||
await useTripStore.getState().deleteDay(tripId, dayId)
|
||||
} catch (err: unknown) {
|
||||
// The store has put the day back already; the server's own sentence is
|
||||
// English, so the toast says it in the traveller's language instead.
|
||||
console.error('Failed to delete day:', err)
|
||||
toast.error(t('dayplan.deleteDayError'))
|
||||
return
|
||||
}
|
||||
onDeleted()
|
||||
toast.success(t('dayplan.deleteDaySuccess'))
|
||||
}, [deleteDayId, tripId, toast, t, onDeleted])
|
||||
|
||||
return { deleteDayId, setDeleteDayId, deleteDayTitle, deleteDayLines, deleteDayBlocked, handleDeleteDay, confirmDeleteDay }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-TP-HOOK-001 to FE-TP-HOOK-119
|
||||
// FE-TP-HOOK-001 to FE-TP-HOOK-125
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
@@ -102,6 +102,7 @@ interface PlannerActions {
|
||||
reorderAssignments: ReturnType<typeof vi.fn>
|
||||
reorderDays: ReturnType<typeof vi.fn>
|
||||
insertDay: ReturnType<typeof vi.fn>
|
||||
deleteDay: ReturnType<typeof vi.fn>
|
||||
updateDayTitle: ReturnType<typeof vi.fn>
|
||||
addReservation: ReturnType<typeof vi.fn>
|
||||
updateReservation: ReturnType<typeof vi.fn>
|
||||
@@ -129,6 +130,7 @@ function makeActions(): PlannerActions {
|
||||
reorderAssignments: vi.fn(async () => undefined),
|
||||
reorderDays: vi.fn(async () => undefined),
|
||||
insertDay: vi.fn(async () => undefined),
|
||||
deleteDay: vi.fn(async () => undefined),
|
||||
updateDayTitle: vi.fn(async () => undefined),
|
||||
addReservation: vi.fn(async () => ({ id: 77 })),
|
||||
updateReservation: vi.fn(async () => ({ id: 77 })),
|
||||
@@ -2270,3 +2272,107 @@ describe('useTripPlanner — dropping a hit on the drive', () => {
|
||||
expect(result.current.stopDraft).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner: deleting a day', () => {
|
||||
// Dated 1 to 3 June on a trip running 1 to 3 June, with a place on the middle day.
|
||||
function seedDays(extra: Partial<TripStoreState> = {}) {
|
||||
const place = buildPlace({ id: 70 })
|
||||
return seedTrip({
|
||||
trip: buildTrip({ id: 42, title: 'Kyoto', start_date: '2026-06-01', end_date: '2026-06-03' }),
|
||||
days: [
|
||||
buildDay({ id: 1, day_number: 1, date: '2026-06-01', title: null }),
|
||||
buildDay({ id: 2, day_number: 2, date: '2026-06-02', title: 'Harbour day' }),
|
||||
buildDay({ id: 3, day_number: 3, date: '2026-06-03', title: null }),
|
||||
],
|
||||
assignments: { '1': [], '2': [buildAssignment({ id: 20, day_id: 2, place })], '3': [] },
|
||||
dayNotes: { '1': [], '2': [], '3': [] },
|
||||
...extra,
|
||||
} as Partial<TripStoreState>)
|
||||
}
|
||||
|
||||
it('FE-TP-HOOK-122: asking needs day_edit; with it the question names the day and lists what goes with it', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 2, role: 'user' }) })
|
||||
usePermissionsStore.setState({ permissions: { day_edit: 'admin' } })
|
||||
seedDays()
|
||||
const denied = await renderPlanner()
|
||||
act(() => { denied.result.current.handleDeleteDay(2) })
|
||||
expect(denied.result.current.deleteDayId).toBeNull()
|
||||
denied.unmount()
|
||||
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }) })
|
||||
usePermissionsStore.setState({ permissions: {} })
|
||||
const { result } = await renderPlanner()
|
||||
act(() => { result.current.handleDeleteDay(2) })
|
||||
|
||||
expect(result.current.deleteDayId).toBe(2)
|
||||
expect(result.current.deleteDayTitle).toBe('Delete Harbour day?')
|
||||
expect(result.current.deleteDayLines.map(l => l.key)).toEqual(['places', 'texts', 'shift', 'shrink'])
|
||||
expect(result.current.deleteDayLines.find(l => l.key === 'places')?.text).toBe('Planned places: 1')
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-123: confirming deletes through the store, closes the question and reloads the stays', async () => {
|
||||
seedDays()
|
||||
const { result } = await renderPlanner()
|
||||
act(() => { result.current.handleDeleteDay(2) })
|
||||
vi.mocked(accommodationRepo.list).mockClear()
|
||||
|
||||
await act(async () => { await result.current.confirmDeleteDay() })
|
||||
|
||||
expect(actions.deleteDay).toHaveBeenCalledWith(42, 2)
|
||||
expect(result.current.deleteDayId).toBeNull()
|
||||
expect(accommodationRepo.list).toHaveBeenCalledWith(42)
|
||||
expect(updateRouteForDay).toHaveBeenCalled()
|
||||
expect(toasts).toContainEqual(expect.objectContaining({ type: 'success', message: 'Day deleted' }))
|
||||
|
||||
// A refusal is said in the traveller's language, and nothing is reloaded.
|
||||
actions.deleteDay.mockRejectedValueOnce(new Error('A trip needs at least one day.'))
|
||||
vi.mocked(accommodationRepo.list).mockClear()
|
||||
act(() => { result.current.handleDeleteDay(3) })
|
||||
await act(async () => { await result.current.confirmDeleteDay() })
|
||||
expect(toasts).toContainEqual(expect.objectContaining({ type: 'error', message: 'Failed to delete day' }))
|
||||
expect(accommodationRepo.list).not.toHaveBeenCalled()
|
||||
|
||||
// Without an open question there is nothing to confirm.
|
||||
actions.deleteDay.mockClear()
|
||||
await act(async () => { await result.current.confirmDeleteDay() })
|
||||
expect(actions.deleteDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-124: the last day and a missing connection block the question, and say why', async () => {
|
||||
seedDays({ days: [buildDay({ id: 1, day_number: 1, date: '2026-06-01' })] } as Partial<TripStoreState>)
|
||||
const single = await renderPlanner()
|
||||
expect(single.result.current.deleteDayBlocked).toBe('A trip needs at least one day')
|
||||
act(() => { single.result.current.handleDeleteDay(1) })
|
||||
expect(single.result.current.deleteDayId).toBeNull()
|
||||
single.unmount()
|
||||
|
||||
env.forcedOffline = true
|
||||
seedDays()
|
||||
const { result } = await renderPlanner()
|
||||
expect(result.current.deleteDayBlocked).toBe('Changing days needs a connection')
|
||||
act(() => { result.current.handleDeleteDay(2) })
|
||||
expect(result.current.deleteDayId).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-125: undoing a day reorder skips the days deleted since, and steps aside once a day was added', async () => {
|
||||
seedDays()
|
||||
const { result } = await renderPlanner()
|
||||
await act(async () => { result.current.handleReorderDays([3, 1, 2]) })
|
||||
await waitFor(() => expect(result.current.canUndo).toBe(true))
|
||||
|
||||
// Day 2 went in the meantime: the old order without it is still one the server takes.
|
||||
useTripStore.setState({ days: [buildDay({ id: 3, day_number: 1 }), buildDay({ id: 1, day_number: 2 })] })
|
||||
await act(async () => { await result.current.undo() })
|
||||
expect(actions.reorderDays).toHaveBeenLastCalledWith(42, [1, 3])
|
||||
|
||||
// A day added since leaves the old order short of one, so the undo does nothing.
|
||||
await act(async () => { result.current.handleReorderDays([1, 3]) })
|
||||
await waitFor(() => expect(result.current.canUndo).toBe(true))
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 3, day_number: 2 }), buildDay({ id: 9, day_number: 3 })],
|
||||
})
|
||||
actions.reorderDays.mockClear()
|
||||
await act(async () => { await result.current.undo() })
|
||||
expect(actions.reorderDays).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
type StoredConnections,
|
||||
} from '../../utils/connectionsVisibility'
|
||||
import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlaces'
|
||||
import { useDayDelete } from './useDayDelete'
|
||||
|
||||
/** Stable empty list so the road trip hook stays inert while its mode is off. */
|
||||
const EMPTY_DAYS: Day[] = []
|
||||
@@ -2508,7 +2509,13 @@ export function useTripPlanner() {
|
||||
tripActions.reorderDays(tripId, orderedIds)
|
||||
.then(() => {
|
||||
pushUndo(t('dayplan.reorderUndo'), async () => {
|
||||
await tripActions.reorderDays(tripId, prevIds)
|
||||
// A day deleted since then drops out of the old order. When the list no
|
||||
// longer matches the days there are (one was added), the old order is
|
||||
// not one the server could take, so the undo steps aside.
|
||||
const live = new Set(useTripStore.getState().days.map(d => d.id))
|
||||
const restorable = prevIds.filter(id => live.has(id))
|
||||
if (restorable.length !== live.size) return
|
||||
await tripActions.reorderDays(tripId, restorable)
|
||||
})
|
||||
})
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError')))
|
||||
@@ -2519,6 +2526,17 @@ export function useTripPlanner() {
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.addDayError')))
|
||||
}, [tripId, toast])
|
||||
|
||||
// A deleted day can take a stay along, and the selected day's route may have
|
||||
// lost its day or its stops.
|
||||
const afterDayDeleted = useCallback(() => {
|
||||
loadAccommodations()
|
||||
updateRouteForDay(useTripStore.getState().selectedDayId)
|
||||
}, [loadAccommodations, updateRouteForDay])
|
||||
const dayDelete = useDayDelete({
|
||||
tripId, trip, days, places: allPlaces, reservations, accommodations: tripAccommodations,
|
||||
canEditDays: can('day_edit', trip), t, locale, toast, onDeleted: afterDayDeleted,
|
||||
})
|
||||
|
||||
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
||||
try {
|
||||
// Imported hotel with a reviewed address but no existing place picked: match
|
||||
@@ -2844,6 +2862,7 @@ export function useTripPlanner() {
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handlePoiClick,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
...dayDelete,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
mapTileUrl, fontStyle, splashDone,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// FE-REPO-DAY-001 to FE-REPO-DAY-004
|
||||
// FE-REPO-DAY-001 to FE-REPO-DAY-006
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import 'fake-indexeddb/auto'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { dayRepo } from './dayRepo'
|
||||
import { offlineDb, clearAll } from '../db/offlineDb'
|
||||
import { buildDay } from '../../tests/helpers/factories'
|
||||
import { buildDay, buildTrip } from '../../tests/helpers/factories'
|
||||
|
||||
function setOnline(v: boolean): void {
|
||||
Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true })
|
||||
@@ -57,3 +57,34 @@ describe('dayRepo.list', () => {
|
||||
await expect(dayRepo.list(5)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dayRepo.remove', () => {
|
||||
it('FE-REPO-DAY-005: online, deletes through the API, drops the cached row and caches the trip it answers with', async () => {
|
||||
await offlineDb.days.bulkPut([buildDay({ id: 41, trip_id: 5 }), buildDay({ id: 42, trip_id: 5 })])
|
||||
const trip = buildTrip({ id: 5, end_date: '2026-06-02' })
|
||||
let deleted = ''
|
||||
server.use(http.delete('/api/trips/5/days/41', ({ request }) => {
|
||||
deleted = new URL(request.url).pathname
|
||||
return HttpResponse.json({ success: true, trip })
|
||||
}))
|
||||
|
||||
const result = await dayRepo.remove(5, 41)
|
||||
|
||||
expect(deleted).toBe('/api/trips/5/days/41')
|
||||
expect(result.trip).toMatchObject({ id: 5, end_date: '2026-06-02' })
|
||||
expect(await offlineDb.days.get(41)).toBeUndefined()
|
||||
expect(await offlineDb.days.get(42)).toBeDefined()
|
||||
expect(await offlineDb.trips.get(5)).toMatchObject({ end_date: '2026-06-02' })
|
||||
})
|
||||
|
||||
it('FE-REPO-DAY-006: offline, refuses without a request and keeps the cached row', async () => {
|
||||
await offlineDb.days.put(buildDay({ id: 51, trip_id: 5 }))
|
||||
const hit = vi.fn()
|
||||
server.use(http.delete('/api/trips/5/days/51', () => { hit(); return HttpResponse.json({ success: true }) }))
|
||||
setOnline(false)
|
||||
|
||||
await expect(dayRepo.remove(5, 51)).rejects.toThrow('Deleting a day needs a connection')
|
||||
expect(hit).not.toHaveBeenCalled()
|
||||
expect(await offlineDb.days.get(51)).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { daysApi } from '../api/client'
|
||||
import { offlineDb, upsertDays } from '../db/offlineDb'
|
||||
import { offlineDb, upsertDays, upsertTrip } from '../db/offlineDb'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Day } from '../types'
|
||||
import type { Day, Trip } from '../types'
|
||||
|
||||
export const dayRepo = {
|
||||
async list(tripId: number | string): Promise<{ days: Day[] }> {
|
||||
@@ -19,4 +20,19 @@ export const dayRepo = {
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a day. Online only, deliberately: the server cancels the stays on the
|
||||
* day, lets go of its bookings and moves the dates of the days after it, and a
|
||||
* queued offline write could not replay that faithfully. The cached row goes
|
||||
* right away, since this tab never hears its own day:deleted, and the trip the
|
||||
* server answers with (new day count, maybe a new end date) replaces the cached one.
|
||||
*/
|
||||
async remove(tripId: number | string, dayId: number): Promise<{ trip?: Trip }> {
|
||||
if (isEffectivelyOffline()) throw new Error('Deleting a day needs a connection')
|
||||
const result = await daysApi.delete(tripId, dayId)
|
||||
await offlineDb.days.delete(dayId)
|
||||
if (result.trip) await upsertTrip(result.trip)
|
||||
return { trip: result.trip }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-TSLICE-DAYS-001 to FE-TSLICE-DAYS-008 (whole-day reorder + insert, #589)
|
||||
// FE-TSLICE-DAYS-001 to FE-TSLICE-DAYS-011 (whole-day reorder + insert, #589; delete)
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildDay, buildReservation } from '../../../tests/helpers/factories';
|
||||
import { buildAssignment, buildDay, buildDayNote, buildReservation, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { useTripStore } from '../tripStore';
|
||||
import type { Day } from '../../types';
|
||||
|
||||
@@ -178,4 +178,74 @@ describe('daysSlice', () => {
|
||||
expect(useTripStore.getState().days.map(d => d.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDay', () => {
|
||||
const seedDayContent = () => seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-03' }),
|
||||
days: datedDays(),
|
||||
assignments: { '1': [], '2': [buildAssignment({ day_id: 2 })], '3': [] },
|
||||
dayNotes: { '1': [], '2': [buildDayNote({ day_id: 2 })], '3': [] },
|
||||
selectedDayId: 2,
|
||||
});
|
||||
|
||||
it('FE-TSLICE-DAYS-009: closes the gap at once, keeps the dates on their slots and takes the trip it is answered with', async () => {
|
||||
seedDayContent();
|
||||
const serverDays = [
|
||||
buildDay({ id: 1, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Arrival' }),
|
||||
buildDay({ id: 3, trip_id: 1, day_number: 2, date: '2025-06-02', title: 'Departure' }),
|
||||
];
|
||||
let optimistic: ReturnType<typeof useTripStore.getState> | null = null;
|
||||
server.use(
|
||||
http.delete('/api/trips/1/days/2', () => {
|
||||
optimistic = useTripStore.getState();
|
||||
return HttpResponse.json({ success: true, trip: buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-02' }) });
|
||||
}),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: serverDays })),
|
||||
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [buildReservation({ id: 5, trip_id: 1, title: 'Let go' })] })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().deleteDay(1, 2);
|
||||
|
||||
const during = optimistic as unknown as ReturnType<typeof useTripStore.getState>;
|
||||
expect(during.days.map(d => [d.id, d.day_number, d.date])).toEqual([[1, 1, '2025-06-01'], [3, 2, '2025-06-02']]);
|
||||
expect('2' in during.assignments).toBe(false);
|
||||
expect('2' in during.dayNotes).toBe(false);
|
||||
const after = useTripStore.getState();
|
||||
expect(after.trip?.end_date).toBe('2025-06-02');
|
||||
expect(after.days.map(d => d.id)).toEqual([1, 3]);
|
||||
expect(after.reservations.map(r => r.title)).toEqual(['Let go']);
|
||||
});
|
||||
|
||||
it('FE-TSLICE-DAYS-010: a refusal puts every field back and throws the server sentence', async () => {
|
||||
seedDayContent();
|
||||
server.use(http.delete('/api/trips/1/days/2', () => HttpResponse.json({ error: 'A trip needs at least one day.' }, { status: 400 })));
|
||||
|
||||
await expect(useTripStore.getState().deleteDay(1, 2)).rejects.toThrow('A trip needs at least one day.');
|
||||
|
||||
const state = useTripStore.getState();
|
||||
expect(state.days.map(d => d.id)).toEqual([1, 2, 3]);
|
||||
expect(state.assignments['2']).toHaveLength(1);
|
||||
expect(state.dayNotes['2']).toHaveLength(1);
|
||||
expect(state.selectedDayId).toBe(2);
|
||||
expect(state.trip?.end_date).toBe('2025-06-03');
|
||||
});
|
||||
|
||||
it('FE-TSLICE-DAYS-011: a selection on the deleted day clears, one on another day stays', async () => {
|
||||
seedDayContent();
|
||||
server.use(
|
||||
http.delete('/api/trips/1/days/:id', () => HttpResponse.json({ success: true })),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: datedDays() })),
|
||||
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().deleteDay(1, 2);
|
||||
expect(useTripStore.getState().selectedDayId).toBeNull();
|
||||
|
||||
seedDayContent();
|
||||
await useTripStore.getState().deleteDay(1, 3);
|
||||
expect(useTripStore.getState().selectedDayId).toBe(2);
|
||||
// Answered without a trip, the store keeps the one it has.
|
||||
expect(useTripStore.getState().trip?.end_date).toBe('2025-06-03');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { daysApi } from '../../api/client'
|
||||
import { dayRepo } from '../../repo/dayRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Day } from '../../types'
|
||||
@@ -10,6 +11,31 @@ type GetState = StoreApi<TripStoreState>['getState']
|
||||
export interface DaysSlice {
|
||||
reorderDays: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
insertDay: (tripId: number | string, position?: number) => Promise<Day | undefined>
|
||||
deleteDay: (tripId: number | string, dayId: number) => Promise<void>
|
||||
}
|
||||
|
||||
type DayState = Pick<TripStoreState, 'days' | 'assignments' | 'dayNotes' | 'selectedDayId'>
|
||||
|
||||
/**
|
||||
* The day state without one day, the way the server leaves it: the days after it
|
||||
* move up one place, and on a dated trip the dates stay on their positions, so
|
||||
* every later day takes the date one slot earlier. The day's assignments and
|
||||
* notes go with it, and a selection on it is cleared.
|
||||
*
|
||||
* Both the optimistic delete below and a collaborator's day:deleted apply this,
|
||||
* so the two ends of the same delete cannot drift apart field by field.
|
||||
*/
|
||||
export function withoutDay(state: DayState, dayId: number): DayState {
|
||||
const ordered = [...state.days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
const dates = ordered.map(d => d.date).filter((d): d is string => !!d).sort()
|
||||
const days = ordered
|
||||
.filter(d => d.id !== dayId)
|
||||
.map((d, i) => ({ ...d, day_number: i + 1, date: dates.length ? (dates[i] ?? null) : d.date }))
|
||||
const assignments = { ...state.assignments }
|
||||
delete assignments[String(dayId)]
|
||||
const dayNotes = { ...state.dayNotes }
|
||||
delete dayNotes[String(dayId)]
|
||||
return { days, assignments, dayNotes, selectedDayId: state.selectedDayId === dayId ? null : state.selectedDayId }
|
||||
}
|
||||
|
||||
export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({
|
||||
@@ -53,4 +79,26 @@ export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({
|
||||
throw new Error(getApiErrorMessage(err, 'Error adding day'))
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a day. The list closes up at once; a refusal (the last day, no
|
||||
// connection, a server error) puts every field back. Afterwards the store pulls
|
||||
// the re-dated days and the bookings, which were re-stamped or let go of, and
|
||||
// takes the trip the server answered with for its day count and end date.
|
||||
deleteDay: async (tripId, dayId) => {
|
||||
const { days, assignments, dayNotes, selectedDayId } = get()
|
||||
const previous: DayState = { days, assignments, dayNotes, selectedDayId }
|
||||
set(withoutDay(previous, dayId))
|
||||
|
||||
const outcome = await dayRepo.remove(tripId, dayId).then(
|
||||
answer => ({ trip: answer.trip }),
|
||||
(failure: unknown) => ({ failure }),
|
||||
)
|
||||
if ('failure' in outcome) {
|
||||
set(previous)
|
||||
throw new Error(getApiErrorMessage(outcome.failure, 'Error deleting day'))
|
||||
}
|
||||
if (outcome.trip) set({ trip: outcome.trip })
|
||||
await get().refreshDays(tripId)
|
||||
await get().loadReservations(tripId)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
import { useAuthStore } from '../authStore'
|
||||
import { mergeAssignmentPlace } from './placesSlice'
|
||||
import { withoutDay } from './daysSlice'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
@@ -330,18 +331,9 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
|
||||
'day:updated': (payload, state) => ({
|
||||
days: state.days.map(d => d.id === (payload.day as Day).id ? payload.day as Day : d),
|
||||
}),
|
||||
'day:deleted': (payload, state) => {
|
||||
const removedDayId = String(payload.dayId)
|
||||
const newAssignments = { ...state.assignments }
|
||||
delete newAssignments[removedDayId]
|
||||
const newDayNotes = { ...state.dayNotes }
|
||||
delete newDayNotes[removedDayId]
|
||||
return {
|
||||
days: state.days.filter(d => d.id !== payload.dayId),
|
||||
assignments: newAssignments,
|
||||
dayNotes: newDayNotes,
|
||||
}
|
||||
},
|
||||
// The same reducer the deleting tab ran optimistically: the later days move up
|
||||
// one place, the dates stay on their positions, a selection on the day clears.
|
||||
'day:deleted': (payload, state) => withoutDay(state, Number(payload.dayId)),
|
||||
'day:reordered': (payload, state) => {
|
||||
// Apply the new order instantly when we know all ids; the authoritative
|
||||
// dates + re-stamped booking times are pulled by the refresh below.
|
||||
@@ -564,6 +556,10 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
||||
if (tripId) get().loadReservations(tripId)
|
||||
}
|
||||
|
||||
// A deleted day cancels a stay that checked in or out on it, and the stays
|
||||
// live in page-local planner state rather than this store.
|
||||
if (type === 'day:deleted') window.dispatchEvent(new CustomEvent('accommodations:refresh'))
|
||||
|
||||
// A reorder/insert re-pins dates and re-stamps booking times server-side, so
|
||||
// pull the authoritative days + reservations for collaborators.
|
||||
if (type === 'day:reordered') {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// FE-UTIL-DAYDEL-001 to FE-UTIL-DAYDEL-008
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildAssignment, buildDay, buildDayNote, buildPlace, buildReservation } from '../../tests/helpers/factories'
|
||||
import { contentOnDays, dayDeleteImpact, deleteDayBlockedReason, type DayContentData } from './dayDeleteImpact'
|
||||
import type { Accommodation, Day } from '../types'
|
||||
|
||||
const stay = (overrides: Partial<Accommodation>): Accommodation =>
|
||||
({ id: 1, trip_id: 1, place_id: null, start_day_id: 0, end_day_id: 0, ...overrides }) as Accommodation
|
||||
|
||||
const empty = (): DayContentData => ({ assignments: {}, dayNotes: {}, reservations: [], accommodations: [] })
|
||||
|
||||
/** Three dated days, 1 to 3 June, and optionally a spare day without a date at the end. */
|
||||
function datedDays(spare = false): Day[] {
|
||||
const days = [
|
||||
buildDay({ id: 1, day_number: 1, date: '2026-06-01' }),
|
||||
buildDay({ id: 2, day_number: 2, date: '2026-06-02' }),
|
||||
buildDay({ id: 3, day_number: 3, date: '2026-06-03' }),
|
||||
]
|
||||
return spare ? [...days, buildDay({ id: 4, day_number: 4, date: null })] : days
|
||||
}
|
||||
|
||||
const range = { start_date: '2026-06-01', end_date: '2026-06-03' }
|
||||
|
||||
describe('contentOnDays', () => {
|
||||
it('FE-UTIL-DAYDEL-001: an empty day holds nothing', () => {
|
||||
const day = buildDay({ id: 7 })
|
||||
expect(contentOnDays([day], empty())).toEqual({ places: 0, notes: 0, texts: 0, bookings: 0, stays: [] })
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYDEL-002: places count once each, and the stop a stay wrote is the stay, not a place', () => {
|
||||
const museum = buildPlace({ id: 50 })
|
||||
const cafe = buildPlace({ id: 51 })
|
||||
const hotel = buildPlace({ id: 52 })
|
||||
const data = {
|
||||
...empty(),
|
||||
assignments: {
|
||||
'7': [
|
||||
buildAssignment({ day_id: 7, place: museum }),
|
||||
buildAssignment({ day_id: 7, place: museum }),
|
||||
buildAssignment({ day_id: 7, place: cafe }),
|
||||
buildAssignment({ day_id: 7, place: hotel, accommodation_id: 9 }),
|
||||
],
|
||||
'8': [buildAssignment({ day_id: 8, place: buildPlace({ id: 53 }) })],
|
||||
},
|
||||
}
|
||||
expect(contentOnDays([buildDay({ id: 7 })], data).places).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYDEL-003: notes count one by one, a day title and a day description one each', () => {
|
||||
const data = { ...empty(), dayNotes: { '7': [buildDayNote({ day_id: 7 }), buildDayNote({ day_id: 7 })] } }
|
||||
expect(contentOnDays([buildDay({ id: 7, title: 'Harbour day', notes: 'Ferry at nine' })], data)).toMatchObject({ notes: 2, texts: 2 })
|
||||
expect(contentOnDays([buildDay({ id: 7, title: 'Harbour day', notes: null })], empty()).texts).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYDEL-004: bookings starting or ending on the day count, a cancelled stay\'s own booking does not', () => {
|
||||
const data = {
|
||||
...empty(),
|
||||
accommodations: [stay({ id: 9, start_day_id: 7, end_day_id: 8 })],
|
||||
reservations: [
|
||||
buildReservation({ id: 1, day_id: 7 }),
|
||||
buildReservation({ id: 2, day_id: 6, end_day_id: 7 }),
|
||||
buildReservation({ id: 3, day_id: 8 }),
|
||||
buildReservation({ id: 4, day_id: 7, type: 'hotel', accommodation_id: '9' }),
|
||||
],
|
||||
}
|
||||
expect(contentOnDays([buildDay({ id: 7 })], data).bookings).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYDEL-005: a stay checking in or out on the day goes, named by its place, with its booking', () => {
|
||||
const data: DayContentData = {
|
||||
...empty(),
|
||||
places: [buildPlace({ id: 60, name: 'Pension Alma' })],
|
||||
accommodations: [
|
||||
stay({ id: 1, start_day_id: 7, end_day_id: 8, place_name: 'Harbour Hotel' }),
|
||||
stay({ id: 2, start_day_id: 5, end_day_id: 7, place_id: 60, reservation_title: 'Alma, two nights' }),
|
||||
stay({ id: 3, start_day_id: 5, end_day_id: 7 }),
|
||||
stay({ id: 4, start_day_id: 6, end_day_id: 8 }),
|
||||
],
|
||||
reservations: [buildReservation({ id: 30, title: 'Harbour Hotel, 1 night', accommodation_id: 1 })],
|
||||
}
|
||||
expect(contentOnDays([buildDay({ id: 7 })], data).stays).toEqual([
|
||||
{ id: 1, name: 'Harbour Hotel', booking: 'Harbour Hotel, 1 night' },
|
||||
{ id: 2, name: 'Pension Alma', booking: 'Alma, two nights' },
|
||||
{ id: 3, name: '', booking: null },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dayDeleteImpact', () => {
|
||||
it('FE-UTIL-DAYDEL-006: with a spare day left, the later dated days move one date and their bookings go along', () => {
|
||||
const days = datedDays(true)
|
||||
const data = {
|
||||
...empty(),
|
||||
reservations: [
|
||||
buildReservation({ id: 1, day_id: 3, reservation_time: '2026-06-03T10:00' }),
|
||||
buildReservation({ id: 2, day_id: 1, end_day_id: 3, reservation_end_time: '2026-06-03T09:00' }),
|
||||
buildReservation({ id: 3, day_id: 3, reservation_time: null }),
|
||||
buildReservation({ id: 4, day_id: 1, reservation_time: '2026-06-01T08:00' }),
|
||||
],
|
||||
}
|
||||
expect(dayDeleteImpact(days[1], days, data, range)).toMatchObject({ shiftedDays: 1, shiftedBookings: 2, newEndDate: null, isLastDay: false })
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYDEL-007: with no spare day the last date goes, and a dated trip ends on the one before', () => {
|
||||
const days = datedDays()
|
||||
expect(dayDeleteImpact(days[0], days, empty(), range)).toMatchObject({ shiftedDays: 2, newEndDate: '2026-06-02' })
|
||||
// A trip without a range keeps it; only its days move.
|
||||
expect(dayDeleteImpact(days[0], days, empty(), { start_date: null, end_date: null }).newEndDate).toBeNull()
|
||||
// The last day of the list moves nothing.
|
||||
expect(dayDeleteImpact(days[2], days, empty(), range)).toMatchObject({ shiftedDays: 0, newEndDate: '2026-06-02' })
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYDEL-008: a spare day moves no date, and the only day of a trip is the last one', () => {
|
||||
const days = datedDays(true)
|
||||
expect(dayDeleteImpact(days[3], days, empty(), range)).toMatchObject({ shiftedDays: 0, shiftedBookings: 0, newEndDate: null })
|
||||
const only = [buildDay({ id: 1, day_number: 1 })]
|
||||
expect(dayDeleteImpact(only[0], only, empty(), null).isLastDay).toBe(true)
|
||||
|
||||
const t = (key: string) => key
|
||||
expect(deleteDayBlockedReason(1, true, t)).toBe('dayplan.deleteDayLast')
|
||||
expect(deleteDayBlockedReason(3, true, t)).toBe('dayplan.daysOffline')
|
||||
expect(deleteDayBlockedReason(3, false, t)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Accommodation, AssignmentsMap, Day, DayNotesMap, Place, Reservation, Trip } from '../types'
|
||||
|
||||
/** What the trip holds on its days, as the store and the planner have it. */
|
||||
export interface DayContentData {
|
||||
assignments: AssignmentsMap
|
||||
dayNotes: DayNotesMap
|
||||
reservations: Reservation[]
|
||||
accommodations: Accommodation[]
|
||||
places?: Place[]
|
||||
}
|
||||
|
||||
/** A stay that goes with the days: checked in or out on one of them. */
|
||||
export interface StayImpact {
|
||||
id: number
|
||||
name: string
|
||||
/** Title of the booking that goes with it, or null when none is linked. */
|
||||
booking: string | null
|
||||
}
|
||||
|
||||
/** What sits on a set of days and would go with them. */
|
||||
export interface DayContent {
|
||||
/** Distinct places planned there. The places themselves stay on the trip. */
|
||||
places: number
|
||||
notes: number
|
||||
/** Day titles and day descriptions. */
|
||||
texts: number
|
||||
/** Bookings on the days, the ones a cancelled stay takes along left out. */
|
||||
bookings: number
|
||||
stays: StayImpact[]
|
||||
}
|
||||
|
||||
/** Deleting one day: its content, and what the dates of the other days do. */
|
||||
export interface DayDeleteImpact extends DayContent {
|
||||
/** Later dated days that take another date. */
|
||||
shiftedDays: number
|
||||
/** Bookings on those days whose date is re-stamped with them. */
|
||||
shiftedBookings: number
|
||||
/** The trip's new last date when the delete takes the last date along. */
|
||||
newEndDate: string | null
|
||||
isLastDay: boolean
|
||||
}
|
||||
|
||||
const byNumber = (a: Day, b: Day): number => (a.day_number ?? 0) - (b.day_number ?? 0)
|
||||
|
||||
/** A reservation's link to a stay, which the wire carries as a number or a string. */
|
||||
const stayOf = (r: Reservation): number | null => (r.accommodation_id == null ? null : Number(r.accommodation_id))
|
||||
|
||||
/**
|
||||
* Count what sits on the given days, the way the server treats it when they go.
|
||||
*
|
||||
* A stop a stay wrote is the stay, not a place of its own, so it is not counted
|
||||
* as one; a stay touching two of the days counts once, and the booking that came
|
||||
* with it is part of the stay rather than one of the bookings. Shared by the day
|
||||
* delete question and the warning before a trip is shortened.
|
||||
*/
|
||||
export function contentOnDays(days: Pick<Day, 'id' | 'title' | 'notes'>[], data: DayContentData): DayContent {
|
||||
const ids = new Set(days.map(d => d.id))
|
||||
const stays = data.accommodations.filter(stay => ids.has(stay.start_day_id) || ids.has(stay.end_day_id))
|
||||
const stayIds = new Set(stays.map(stay => stay.id))
|
||||
|
||||
const placeIds = new Set<number>()
|
||||
let notes = 0
|
||||
let texts = 0
|
||||
for (const day of days) {
|
||||
for (const assignment of data.assignments[String(day.id)] ?? []) {
|
||||
if (assignment.accommodation_id == null) placeIds.add(assignment.place.id)
|
||||
}
|
||||
notes += (data.dayNotes[String(day.id)] ?? []).length
|
||||
if (day.title) texts += 1
|
||||
if (day.notes) texts += 1
|
||||
}
|
||||
|
||||
const onDays = (r: Reservation) => (r.day_id != null && ids.has(r.day_id)) || (r.end_day_id != null && ids.has(r.end_day_id))
|
||||
const bookings = data.reservations.filter(r => onDays(r) && !stayIds.has(stayOf(r) ?? -1)).length
|
||||
|
||||
return {
|
||||
places: placeIds.size,
|
||||
notes,
|
||||
texts,
|
||||
bookings,
|
||||
stays: stays.map(stay => {
|
||||
const booking = data.reservations.find(r => stayOf(r) === stay.id)?.title ?? stay.reservation_title ?? null
|
||||
const name = stay.place_name ?? data.places?.find(p => p.id === stay.place_id)?.name ?? booking ?? ''
|
||||
return { id: stay.id, name, booking }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What deleting one day does, as the server will do it (DayRemovalService): the
|
||||
* dates stay on their positions, so every later dated day takes the date one slot
|
||||
* earlier and the bookings on it are re-stamped along. When no day without a date
|
||||
* is left to take the last date, the last date goes and a dated trip ends a day
|
||||
* earlier.
|
||||
*/
|
||||
export function dayDeleteImpact(
|
||||
day: Day,
|
||||
days: Day[],
|
||||
data: DayContentData,
|
||||
trip: Pick<Trip, 'start_date' | 'end_date'> | null,
|
||||
): DayDeleteImpact {
|
||||
const content = contentOnDays([day], data)
|
||||
const ordered = [...days].sort(byNumber)
|
||||
const remaining = ordered.filter(d => d.id !== day.id)
|
||||
const dates = ordered.map(d => d.date).filter((d): d is string => !!d).sort()
|
||||
|
||||
const moved = new Set<number>()
|
||||
remaining.forEach((d, i) => {
|
||||
const next = dates[i]
|
||||
if (d.date && next && next !== d.date) moved.add(d.id)
|
||||
})
|
||||
const cancelled = new Set(content.stays.map(stay => stay.id))
|
||||
const shiftedBookings = data.reservations.filter(r => {
|
||||
if (cancelled.has(stayOf(r) ?? -1)) return false
|
||||
const start = r.day_id != null && moved.has(r.day_id) && !!r.reservation_time
|
||||
const end = r.end_day_id != null && moved.has(r.end_day_id) && !!r.reservation_end_time
|
||||
return start || end
|
||||
}).length
|
||||
|
||||
const dropsDate = remaining.length < dates.length
|
||||
const newEndDate = dropsDate && trip?.start_date && trip.end_date ? (dates[remaining.length - 1] ?? null) : null
|
||||
|
||||
return { ...content, shiftedDays: moved.size, shiftedBookings, newEndDate, isLastDay: days.length <= 1 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a day cannot be deleted right now, or null when it can. A trip keeps at
|
||||
* least one day, and the delete runs on the server only: the cascade behind it
|
||||
* (stays cancelled, bookings let go of, dates moved) is not something a queued
|
||||
* offline write could replay faithfully.
|
||||
*/
|
||||
export function deleteDayBlockedReason(dayCount: number, offline: boolean, t: (key: string) => string): string | null {
|
||||
if (dayCount <= 1) return t('dayplan.deleteDayLast')
|
||||
if (offline) return t('dayplan.daysOffline')
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// FE-UTIL-DAYLINES-001 to FE-UTIL-DAYLINES-004
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { BedDouble, CalendarCheck } from 'lucide-react'
|
||||
import { deleteDayLines, impactLines } from './dayImpactLines'
|
||||
import type { DayDeleteImpact } from './dayDeleteImpact'
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
|
||||
|
||||
const nothing: DayDeleteImpact = {
|
||||
places: 0, notes: 0, texts: 0, bookings: 0, stays: [],
|
||||
shiftedDays: 0, shiftedBookings: 0, newEndDate: null, isLastDay: false,
|
||||
}
|
||||
|
||||
describe('impactLines', () => {
|
||||
it('FE-UTIL-DAYLINES-001: stays come first in the danger tone, then every kind that has something in it', () => {
|
||||
const lines = impactLines({
|
||||
places: 3, notes: 2, texts: 1, bookings: 4,
|
||||
stays: [{ id: 9, name: 'Harbour Hotel', booking: 'HH 1 night' }, { id: 10, name: 'Pension Alma', booking: null }],
|
||||
}, t, 'deleteDay')
|
||||
|
||||
expect(lines.map(l => [l.key, l.tone, l.text, l.hint])).toEqual([
|
||||
['stay-9', 'danger', 'dayplan.impactStay(name=Harbour Hotel)', 'dayplan.deleteDayStayBookedHint(booking=HH 1 night)'],
|
||||
['stay-10', 'danger', 'dayplan.impactStay(name=Pension Alma)', 'dayplan.deleteDayStayHint'],
|
||||
['places', 'neutral', 'dayplan.impactPlaces(count=3)', 'dayplan.impactPlacesHint'],
|
||||
['notes', 'neutral', 'dayplan.impactNotes(count=2)', 'dayplan.impactDeletedHint'],
|
||||
['texts', 'neutral', 'dayplan.impactTexts(count=1)', 'dayplan.impactDeletedHint'],
|
||||
['bookings', 'neutral', 'dayplan.impactBookings(count=4)', 'dayplan.deleteDayBookingsHint'],
|
||||
])
|
||||
expect(lines[0].icon).toBe(BedDouble)
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYLINES-002: kinds with nothing in them are left out', () => {
|
||||
expect(impactLines({ ...nothing, notes: 1 }, t, 'deleteDay').map(l => l.key)).toEqual(['notes'])
|
||||
expect(impactLines(nothing, t, 'deleteDay')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteDayLines', () => {
|
||||
const formatDate = (iso: string) => `<${iso}>`
|
||||
|
||||
it('FE-UTIL-DAYLINES-003: moved dates follow the content, with the bookings that move along and the new end', () => {
|
||||
const lines = deleteDayLines({ ...nothing, places: 1, shiftedDays: 2, shiftedBookings: 3, newEndDate: '2026-06-02' }, t, formatDate)
|
||||
expect(lines.map(l => [l.key, l.tone, l.text, l.hint])).toEqual([
|
||||
['places', 'neutral', 'dayplan.impactPlaces(count=1)', 'dayplan.impactPlacesHint'],
|
||||
['shift', 'warning', 'dayplan.deleteDayShift(count=2)', 'dayplan.deleteDayShiftBookingsHint(count=3)'],
|
||||
['shrink', 'warning', 'dayplan.deleteDayShrink(date=<2026-06-02>)', 'dayplan.deleteDayShrinkHint'],
|
||||
])
|
||||
expect(deleteDayLines({ ...nothing, shiftedDays: 1 }, t, formatDate)[0].hint).toBe('dayplan.deleteDayShiftHint')
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYLINES-004: a day with nothing on it and no dates to move gets one quiet row', () => {
|
||||
const lines = deleteDayLines(nothing, t, formatDate)
|
||||
expect(lines).toEqual([{ key: 'empty', icon: CalendarCheck, tone: 'muted', text: 'dayplan.deleteDayEmpty' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { BedDouble, CalendarCheck, CalendarClock, CalendarMinus, MapPin, StickyNote, Ticket, Type } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { DayContent, DayDeleteImpact } from './dayDeleteImpact'
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
/**
|
||||
* How loud a line is. `danger` is money: a stay cancelled with its booking and
|
||||
* expense. `warning` is dates that move. `muted` is the reassurance that nothing
|
||||
* is lost at all.
|
||||
*/
|
||||
export type ImpactTone = 'neutral' | 'muted' | 'warning' | 'danger'
|
||||
|
||||
/** One row of a warning list, ready for either shell to render. */
|
||||
export interface ImpactLine {
|
||||
key: string
|
||||
icon: LucideIcon
|
||||
text: string
|
||||
hint?: string
|
||||
tone: ImpactTone
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export type ImpactVariant = 'deleteDay'
|
||||
|
||||
const HINTS: Record<ImpactVariant, { bookings: string; stay: string; stayBooked: string }> = {
|
||||
deleteDay: {
|
||||
bookings: 'dayplan.deleteDayBookingsHint',
|
||||
stay: 'dayplan.deleteDayStayHint',
|
||||
stayBooked: 'dayplan.deleteDayStayBookedHint',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for what sits on the days that go: stays first, because they cost
|
||||
* 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]
|
||||
const lines: ImpactLine[] = content.stays.map(stay => ({
|
||||
key: `stay-${stay.id}`,
|
||||
icon: BedDouble,
|
||||
tone: 'danger',
|
||||
text: t('dayplan.impactStay', { name: stay.name }),
|
||||
hint: stay.booking ? t(hints.stayBooked, { booking: stay.booking }) : t(hints.stay),
|
||||
}))
|
||||
if (content.places > 0) {
|
||||
lines.push({ key: 'places', icon: MapPin, tone: 'neutral', text: t('dayplan.impactPlaces', { count: content.places }), hint: t('dayplan.impactPlacesHint') })
|
||||
}
|
||||
if (content.notes > 0) {
|
||||
lines.push({ key: 'notes', icon: StickyNote, tone: 'neutral', text: t('dayplan.impactNotes', { count: content.notes }), hint: t('dayplan.impactDeletedHint') })
|
||||
}
|
||||
if (content.texts > 0) {
|
||||
lines.push({ key: 'texts', icon: Type, tone: 'neutral', text: t('dayplan.impactTexts', { count: content.texts }), hint: t('dayplan.impactDeletedHint') })
|
||||
}
|
||||
if (content.bookings > 0) {
|
||||
lines.push({ key: 'bookings', icon: Ticket, tone: 'neutral', text: t('dayplan.impactBookings', { count: content.bookings }), hint: t(hints.bookings) })
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The full list for the delete question: the content rows, then what happens to
|
||||
* the dates, and a single quiet row for a day with nothing on it.
|
||||
*/
|
||||
export function deleteDayLines(impact: DayDeleteImpact, t: Translate, formatDate: (iso: string) => string): ImpactLine[] {
|
||||
const lines = impactLines(impact, t, 'deleteDay')
|
||||
if (impact.shiftedDays > 0) {
|
||||
lines.push({
|
||||
key: 'shift',
|
||||
icon: CalendarClock,
|
||||
tone: 'warning',
|
||||
text: t('dayplan.deleteDayShift', { count: impact.shiftedDays }),
|
||||
hint: impact.shiftedBookings > 0
|
||||
? t('dayplan.deleteDayShiftBookingsHint', { count: impact.shiftedBookings })
|
||||
: t('dayplan.deleteDayShiftHint'),
|
||||
})
|
||||
}
|
||||
if (impact.newEndDate) {
|
||||
lines.push({
|
||||
key: 'shrink',
|
||||
icon: CalendarMinus,
|
||||
tone: 'warning',
|
||||
text: t('dayplan.deleteDayShrink', { date: formatDate(impact.newEndDate) }),
|
||||
hint: t('dayplan.deleteDayShrinkHint'),
|
||||
})
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
lines.push({ key: 'empty', icon: CalendarCheck, tone: 'muted', text: t('dayplan.deleteDayEmpty') })
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// FE-UTIL-DAYLABEL-001 to FE-UTIL-DAYLABEL-003
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { dayLabel } from './dayLabel'
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) => `${key}:${params?.n}`
|
||||
|
||||
describe('dayLabel', () => {
|
||||
it('FE-UTIL-DAYLABEL-001: a title wins over the date', () => {
|
||||
expect(dayLabel({ title: 'Old Town', date: '2026-05-02' }, 1, t, 'en-US')).toBe('Old Town')
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYLABEL-002: without a title the date reads as a short local weekday and day', () => {
|
||||
expect(dayLabel({ title: null, date: '2026-05-01' }, 0, t, 'en-US')).toBe('Fri, May 1')
|
||||
// A full timestamp names the same calendar day.
|
||||
expect(dayLabel({ title: '', date: '2026-05-03T09:30:00' }, 2, t, 'en-US')).toBe('Sun, May 3')
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYLABEL-003: no title and no usable date falls back to the position, counted from one', () => {
|
||||
expect(dayLabel({ title: null, date: null }, 0, t, 'en-US')).toBe('dayplan.dayN:1')
|
||||
expect(dayLabel({ title: null, date: 'not-a-date' }, 4, t, 'en-US')).toBe('dayplan.dayN:5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Day } from '../types'
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
/**
|
||||
* How a day is named wherever days are listed as a whole: its title, else its
|
||||
* date as a short weekday and day of the month, else "Day n" by its position.
|
||||
*
|
||||
* The reorder dialog, the phone's day sheet and the delete question all name a
|
||||
* day, and they have to name it the same way, or the question asks about a day
|
||||
* the list beside it calls something else. The date is read as a local calendar
|
||||
* date, and a value that does not parse falls through to the position.
|
||||
*/
|
||||
export function dayLabel(day: Pick<Day, 'title' | 'date'>, index: number, t: Translate, locale: string): string {
|
||||
if (day.title) return day.title
|
||||
if (day.date) {
|
||||
const date = new Date(`${day.date.slice(0, 10)}T00:00:00`)
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return date.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
}
|
||||
return t('dayplan.dayN', { n: index + 1 })
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function buildTripActions(): Record<string, ReturnType<typeof vi.fn>> {
|
||||
'addCategory', 'addDayNote', 'addFile', 'addPackingContributor', 'addPackingItem',
|
||||
'addPlace', 'addReservation', 'addTodoItem', 'assignPlaceToDay', 'clonePackingItem',
|
||||
'deleteBudgetItem', 'deleteDayNote', 'deleteFile', 'deletePackingItem', 'deletePlace',
|
||||
'deletePlacesMany', 'deleteReservation', 'deleteTodoItem', 'insertDay', 'loadBudgetItems',
|
||||
'deleteDay', 'deletePlacesMany', 'deleteReservation', 'deleteTodoItem', 'insertDay', 'loadBudgetItems',
|
||||
'loadFiles', 'loadReservations', 'loadTrip', 'moveAssignment', 'moveDayNote', 'ratePlace',
|
||||
'refreshDays', 'removeAssignment', 'removePackingContributor', 'reorderAssignments',
|
||||
'reorderDays', 'setAssignments', 'setPackingItemSharing', 'setSelectedDay',
|
||||
@@ -185,6 +185,15 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
|
||||
deletePlaceIds: null,
|
||||
setDeletePlaceIds: vi.fn(),
|
||||
|
||||
// Deleting a day from the days sheet: no question open, nothing blocking.
|
||||
deleteDayId: null,
|
||||
setDeleteDayId: vi.fn(),
|
||||
deleteDayTitle: '',
|
||||
deleteDayLines: [],
|
||||
deleteDayBlocked: null,
|
||||
handleDeleteDay: vi.fn(),
|
||||
confirmDeleteDay: vi.fn(async () => undefined),
|
||||
|
||||
// resolveVisibleConnectionIds returns an array, and consumers call .includes().
|
||||
visibleConnections: [] as number[],
|
||||
roadtripConnections: [] as number[],
|
||||
|
||||
@@ -6,7 +6,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-DAYSS-001 to FE-MOB-DAYSS-012
|
||||
// FE-MOB-DAYSS-001 to FE-MOB-DAYSS-015
|
||||
//
|
||||
// The sheet reads its copy from the real TranslationProvider (useTranslation),
|
||||
// not from planner.t — assertions therefore go against the English strings.
|
||||
@@ -120,4 +120,27 @@ describe('MDaysSheet', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-013: each row asks the planner to delete its own day', () => {
|
||||
const { planner } = renderSheet()
|
||||
const buttons = screen.getAllByRole('button', { name: 'Delete day' })
|
||||
expect(buttons).toHaveLength(3)
|
||||
// Rows run in day_number order: the second one is day 2.
|
||||
fireEvent.click(buttons[1])
|
||||
expect(planner.handleDeleteDay).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-014: a read-only member gets no delete buttons', () => {
|
||||
renderSheet({ can: vi.fn(() => false) })
|
||||
expect(screen.queryByRole('button', { name: 'Delete day' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-015: while the planner blocks deleting, the buttons are off and the sheet says why', () => {
|
||||
const { planner } = renderSheet({ deleteDayBlocked: 'A trip needs at least one day' })
|
||||
const buttons = screen.getAllByRole('button', { name: 'Delete day' })
|
||||
expect(buttons.every(b => (b as HTMLButtonElement).disabled)).toBe(true)
|
||||
fireEvent.click(buttons[0])
|
||||
expect(planner.handleDeleteDay).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('A trip needs at least one day')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import { BedDouble, MapPin } from 'lucide-react'
|
||||
import type { BookingExpenseRequest } from '../../../../src/components/Planner/BookingCostsSection.types'
|
||||
import type { ExpensePrefill } from '../../../../src/components/Budget/CostsPanel'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
@@ -11,7 +12,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-SHOST-001 to FE-MOB-SHOST-029
|
||||
// FE-MOB-SHOST-001 to FE-MOB-SHOST-030
|
||||
//
|
||||
// Every child sheet is stubbed: this file is about the host — which sheet is
|
||||
// mounted for which shell.sheet id, and how the host's own callbacks wire the
|
||||
@@ -101,12 +102,14 @@ vi.mock('../../../../src/mobile/screens/trip/sheets/MCostSheet', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/settings/MConfirmSheet', () => ({
|
||||
default: ({ open, title, message, onClose, onConfirm }: {
|
||||
default: ({ open, title, message, onClose, onConfirm, danger, confirmLabel, children }: {
|
||||
open: boolean; title: string; message: ReactNode; onClose: () => void; onConfirm?: () => void
|
||||
danger?: boolean; confirmLabel?: string; children?: ReactNode
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="stub-confirm" data-title={title}>
|
||||
<div data-testid="stub-confirm" data-title={title} data-danger={String(!!danger)} data-confirm={confirmLabel}>
|
||||
<span>{message}</span>
|
||||
{children}
|
||||
<button type="button" onClick={onConfirm}>confirm delete</button>
|
||||
<button type="button" onClick={onClose}>cancel delete</button>
|
||||
</div>
|
||||
@@ -444,6 +447,28 @@ describe('MTripSheets', () => {
|
||||
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-030: the delete-day confirm names the day, lists what goes with it and runs the planner confirmation', () => {
|
||||
const lines = [
|
||||
{ key: 'stay-9', icon: BedDouble, tone: 'danger' as const, text: 'Stay at Harbour Hotel', hint: 'Cancelled with its booking.' },
|
||||
{ key: 'places', icon: MapPin, tone: 'neutral' as const, text: 'Planned places: 2' },
|
||||
]
|
||||
const { planner } = renderHost({ deleteDayId: 12, deleteDayTitle: 'Delete Tue, Oct 13?', deleteDayLines: lines })
|
||||
|
||||
const confirm = screen.getByTestId('stub-confirm')
|
||||
expect(confirm).toHaveAttribute('data-title', 'Delete Tue, Oct 13?')
|
||||
expect(confirm).toHaveAttribute('data-danger', 'true')
|
||||
expect(confirm).toHaveAttribute('data-confirm', 'dayplan.deleteDay')
|
||||
expect(confirm).toHaveTextContent('dayplan.deleteDayBody')
|
||||
const list = screen.getByRole('list', { name: 'Delete Tue, Oct 13?' })
|
||||
expect(list).toHaveTextContent('Stay at Harbour Hotel')
|
||||
expect(list).toHaveTextContent('Planned places: 2')
|
||||
|
||||
fireEvent.click(screen.getByText('confirm delete'))
|
||||
expect(planner.confirmDeleteDay).toHaveBeenCalledTimes(1)
|
||||
fireEvent.click(screen.getByText('cancel delete'))
|
||||
expect(planner.setDeleteDayId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-027: the place edit sheet owns the confirm while its own form is open', () => {
|
||||
renderHost({ deletePlaceId: 101, showPlaceForm: true })
|
||||
expect(screen.queryByTestId('stub-confirm')).not.toBeInTheDocument()
|
||||
|
||||
@@ -78,6 +78,47 @@ describe('remoteEventHandler > days', () => {
|
||||
expect('20' in dayNotes).toBe(true);
|
||||
});
|
||||
|
||||
// A delete now closes the gap on the server and keeps the dates on their slots;
|
||||
// the collaborators run the same reducer the deleting tab ran optimistically.
|
||||
it('FE-WSEVT-DAY-014: day:deleted then day:reordered leaves the same numbering and dates as the server', () => {
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: 7 }),
|
||||
selectedDayId: 20,
|
||||
days: [
|
||||
buildDay({ id: 10, day_number: 1, date: '2026-06-01' }),
|
||||
buildDay({ id: 20, day_number: 2, date: '2026-06-02' }),
|
||||
buildDay({ id: 30, day_number: 3, date: '2026-06-03' }),
|
||||
buildDay({ id: 40, day_number: 4, date: null }),
|
||||
],
|
||||
refreshDays: vi.fn(async () => {}),
|
||||
loadReservations: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 20 });
|
||||
const afterDelete = useTripStore.getState();
|
||||
expect(afterDelete.days.map(d => [d.id, d.day_number, d.date])).toEqual([
|
||||
[10, 1, '2026-06-01'], [30, 2, '2026-06-02'], [40, 3, '2026-06-03'],
|
||||
]);
|
||||
expect(afterDelete.selectedDayId).toBeNull();
|
||||
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'day:reordered', orderedIds: [10, 30, 40] });
|
||||
expect(useTripStore.getState().days.map(d => [d.id, d.day_number])).toEqual([[10, 1], [30, 2], [40, 3]]);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-DAY-015: day:deleted asks the planner to reload its stays, which a deleted day can cancel', () => {
|
||||
seedData();
|
||||
const refresh = vi.fn();
|
||||
window.addEventListener('accommodations:refresh', refresh);
|
||||
try {
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 10 });
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'day:updated', day: buildDay({ id: 20 }) });
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
window.removeEventListener('accommodations:refresh', refresh);
|
||||
}
|
||||
});
|
||||
|
||||
// The reorder is applied optimistically from orderedIds, then the authoritative
|
||||
// dates + re-stamped booking times are pulled (#589).
|
||||
describe('day:reordered', () => {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { RoadtripDayBoundary } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AccommodationsService, type AccommodationMirror, type MirrorSender } from '../accommodations/accommodations.service';
|
||||
import { AssignmentsService } from '../assignments/assignments.service';
|
||||
import { DaysService } from './days.service';
|
||||
|
||||
/** A day that has to stay where it is; REST answers 400, MCP a tool error, a plugin BadParams. */
|
||||
export class DayDeleteError extends Error {}
|
||||
|
||||
/** The refusal every surface gives for the last day of a trip, word for word. */
|
||||
export const LAST_DAY_MESSAGE = 'A trip needs at least one day.';
|
||||
|
||||
/** What deleting one day did to the trip, for the surface that announces it. */
|
||||
export interface DayRemoval {
|
||||
dayId: number;
|
||||
/** The days that are left, in their new order. Their day_number is their position now. */
|
||||
orderedIds: number[];
|
||||
/** Stays that checked in or out on the day. They are cancelled with it. */
|
||||
stayIds: number[];
|
||||
/** The bookings those stays brought, deleted with them. */
|
||||
reservationIds: number[];
|
||||
/** The expenses written against those bookings. */
|
||||
budgetItemIds: number[];
|
||||
/** What cancelling each stay did to the day plan of the days that are left. */
|
||||
mirrors: AccommodationMirror[];
|
||||
/** The road trip day boundaries after they moved up with their days, or null when none changed. */
|
||||
boundaries: RoadtripDayBoundary[] | null;
|
||||
/** The trip's new last date when the day took it along, or null when the range stayed. */
|
||||
endDate: string | null;
|
||||
/** The trip re-read in list shape: its day count changed, and maybe its end date. */
|
||||
trip: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a surface sends a removal. `all` reaches every socket of the trip, the one
|
||||
* that asked included; `others` leaves that one out because its own answer
|
||||
* already carries the change. The MCP tools and the plugin RPC have no socket
|
||||
* of their own, so they hand the same sender twice.
|
||||
*/
|
||||
export interface DayRemovalSenders {
|
||||
all: MirrorSender;
|
||||
others: MirrorSender;
|
||||
socketId?: string;
|
||||
}
|
||||
|
||||
type DayRow = { id: number; day_number: number; date: string | null };
|
||||
|
||||
/**
|
||||
* Deleting a day, for the three surfaces that offer it (REST, MCP, plugin RPC).
|
||||
*
|
||||
* The delete used to be a bare DELETE: the day numbers kept a hole, the dates
|
||||
* stayed where they were, a stay checking in or out on the day vanished by
|
||||
* cascade while its booking and that booking's expense stayed behind pointing
|
||||
* nowhere, and the journey was never told. It is the exact reverse of an insert
|
||||
* now. The later days move up one place, and on a dated trip the dates stay
|
||||
* pinned to their positions the way a reorder keeps them: every later day, with
|
||||
* its bookings, takes the date one slot earlier. A day without a date takes the
|
||||
* last date if the trip has one; otherwise the last date goes, and the trip ends
|
||||
* one day earlier, which is what insert does the other way round.
|
||||
*
|
||||
* Its own class rather than a method on DaysService because it needs the
|
||||
* accommodations and assignments services, and DaysService is built by hand in
|
||||
* enough suites that a wider constructor there would ripple through all of them.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DayRemovalService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly days: DaysService,
|
||||
private readonly accommodations: AccommodationsService,
|
||||
private readonly assignments: AssignmentsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Delete the day in one transaction. Nothing is written when it throws, and it
|
||||
* throws a DayDeleteError for the last day of a trip. The journey catches up
|
||||
* afterwards, outside the transaction, and a failure there does not undo the delete.
|
||||
*/
|
||||
remove(tripId: string | number, dayId: string | number, viewer: { userId: number; socketId?: string }): DayRemoval {
|
||||
const trip = Number(tripId);
|
||||
const id = Number(dayId);
|
||||
|
||||
const removal = this.db.transaction(() => {
|
||||
const rows = this.db.all<DayRow>('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number', trip);
|
||||
const target = rows.find(r => r.id === id);
|
||||
if (!target) throw new DayDeleteError('Day not found');
|
||||
if (rows.length <= 1) throw new DayDeleteError(LAST_DAY_MESSAGE);
|
||||
|
||||
const cancelled = this.cancelStays(trip, id);
|
||||
const boundariesBefore = this.boundaries(trip);
|
||||
|
||||
// The boundary drawn on this day goes with it; the day row takes its
|
||||
// assignments, notes, stops, roads and booking positions along by cascade.
|
||||
this.db.run('DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?', trip, target.day_number);
|
||||
this.db.run('DELETE FROM days WHERE id = ?', id);
|
||||
this.shiftBoundaries(trip, target.day_number);
|
||||
|
||||
const remaining = rows.filter(r => r.id !== id);
|
||||
const endDate = this.renumber(trip, rows, remaining);
|
||||
|
||||
const boundariesAfter = this.boundaries(trip);
|
||||
const boundariesChanged = JSON.stringify(boundariesAfter) !== JSON.stringify(boundariesBefore);
|
||||
|
||||
return {
|
||||
dayId: id,
|
||||
orderedIds: remaining.map(r => r.id),
|
||||
...cancelled,
|
||||
boundaries: boundariesChanged ? boundariesAfter : null,
|
||||
endDate,
|
||||
};
|
||||
});
|
||||
|
||||
// After the commit, the way an assignment route does it: the journey mirrors
|
||||
// the planned stops, and the day's stops are gone. reconcile() swallows its own
|
||||
// failures, so a journey problem cannot turn a finished delete into an error.
|
||||
this.assignments.reconcile(trip, viewer.socketId);
|
||||
return { ...removal, trip: this.days.getTripForViewer(trip, viewer.userId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a removal is fanned out. The collaborators drop the day first
|
||||
* and then renumber off day:reordered, which also makes them pull the re-dated
|
||||
* days and re-stamped bookings. The cancelled stays follow with what they did to
|
||||
* the other days, then the rows they took with them.
|
||||
*/
|
||||
announce(tripId: string | number, removal: DayRemoval, senders: DayRemovalSenders): void {
|
||||
const { all, others, socketId } = senders;
|
||||
others('day:deleted', { dayId: removal.dayId });
|
||||
others('day:reordered', { orderedIds: removal.orderedIds });
|
||||
for (const mirror of removal.mirrors) this.accommodations.announceMirror(tripId, mirror, all, socketId);
|
||||
// Without a socket id, as when a place takes its nights with it: the Bookings
|
||||
// list and the Costs total of the deleting tab hold these rows too.
|
||||
for (const reservationId of removal.reservationIds) all('reservation:deleted', { reservationId });
|
||||
for (const itemId of removal.budgetItemIds) all('budget:deleted', { itemId });
|
||||
for (const accommodationId of removal.stayIds) others('accommodation:deleted', { accommodationId });
|
||||
if (removal.boundaries) all('roadtripBoundary:changed', { boundaries: removal.boundaries });
|
||||
// The day count changed, and the end date when the last date went: the trip
|
||||
// header of every other open copy of the trip reads both.
|
||||
if (removal.trip) others('trip:updated', { trip: removal.trip });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel every stay that checks in or out on the day, the way deleting it by
|
||||
* hand does: the partner booking, its expense and the stop it wrote go too.
|
||||
* Left to the cascade, the stay row alone would go and the rest stay behind.
|
||||
* A stay that only runs across the day keeps standing.
|
||||
*/
|
||||
private cancelStays(tripId: number, dayId: number) {
|
||||
const stays = this.db.all<{ id: number }>(
|
||||
'SELECT id FROM day_accommodations WHERE trip_id = ? AND (start_day_id = ? OR end_day_id = ?) ORDER BY id',
|
||||
tripId, dayId, dayId,
|
||||
);
|
||||
const cancelled = { stayIds: [] as number[], reservationIds: [] as number[], budgetItemIds: [] as number[], mirrors: [] as AccommodationMirror[] };
|
||||
for (const stay of stays) {
|
||||
const gone = this.accommodations.deleteAccommodation(stay.id);
|
||||
cancelled.stayIds.push(stay.id);
|
||||
cancelled.reservationIds.push(...gone.linkedReservationIds);
|
||||
cancelled.budgetItemIds.push(...gone.deletedBudgetItemIds);
|
||||
cancelled.mirrors.push(withoutDay(gone.mirror, dayId));
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
private boundaries(tripId: number): RoadtripDayBoundary[] {
|
||||
return this.db.all<RoadtripDayBoundary>(
|
||||
'SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number',
|
||||
tripId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundaries are keyed by day number, not by day, so the ones after the deleted
|
||||
* day move up with their days. One at a time and in ascending order: the key is
|
||||
* the primary key, and a CHECK keeps it at 1 or above, so the negative two-step
|
||||
* the days use is not available here.
|
||||
*/
|
||||
private shiftBoundaries(tripId: number, deletedNumber: number): void {
|
||||
const later = this.db.all<{ day_number: number }>(
|
||||
'SELECT day_number FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number > ? ORDER BY day_number',
|
||||
tripId, deletedNumber,
|
||||
);
|
||||
const move = this.db.prepare('UPDATE roadtrip_day_boundaries SET day_number = ? WHERE trip_id = ? AND day_number = ?');
|
||||
for (const row of later) move.run(row.day_number - 1, tripId, row.day_number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the gap and keep the dates on their positions: position i takes the
|
||||
* i-th date of the trip as it was, the deleted day's date included. Bookings on
|
||||
* a day that changed date are re-stamped onto it. When there are fewer days left
|
||||
* than dates, the last date is gone, and a dated trip ends on the new last one.
|
||||
* Returns that new end date, or null when the range stayed.
|
||||
*/
|
||||
private renumber(tripId: number, rows: DayRow[], remaining: DayRow[]): string | null {
|
||||
// ISO dates sort as plain strings.
|
||||
const sortedDates = rows.map(r => r.date).filter((d): d is string => !!d).sort();
|
||||
const setNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
const setNumberAndDate = this.db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
|
||||
|
||||
// Two phases, to get past UNIQUE(trip_id, day_number) on the way.
|
||||
remaining.forEach((r, i) => setNumber.run(-(i + 1), r.id));
|
||||
const oldDateById = new Map(remaining.map(r => [r.id, r.date]));
|
||||
const newDateById = new Map<number, string | null>();
|
||||
remaining.forEach((r, i) => {
|
||||
const date = sortedDates[i] ?? null;
|
||||
setNumberAndDate.run(i + 1, date, r.id);
|
||||
newDateById.set(r.id, date);
|
||||
});
|
||||
if (sortedDates.length > 0) this.days.restampReservationDates(tripId, oldDateById, newDateById);
|
||||
|
||||
if (remaining.length >= sortedDates.length) return null;
|
||||
const range = this.db.get<{ start_date: string | null; end_date: string | null }>(
|
||||
'SELECT start_date, end_date FROM trips WHERE id = ?', tripId,
|
||||
);
|
||||
if (!range?.start_date || !range.end_date) return null;
|
||||
const endDate = sortedDates[remaining.length - 1];
|
||||
this.db.run('UPDATE trips SET end_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', endDate, tripId);
|
||||
return endDate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cancelled stay's mirror without the deleted day. Its stop there went with the
|
||||
* day, and the collaborators drop the whole day on day:deleted, so announcing that
|
||||
* stop, the day's order or its roads on top would name a day nobody holds anymore.
|
||||
*/
|
||||
function withoutDay(mirror: AccommodationMirror, dayId: number): AccommodationMirror {
|
||||
return {
|
||||
...mirror,
|
||||
removed: mirror.removed.filter(stop => stop.dayId !== dayId),
|
||||
...(mirror.vias ? { vias: mirror.vias.filter(day => day.dayId !== dayId) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { DaysService, DayReorderError } from './days.service';
|
||||
import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service';
|
||||
import { DayCreateDto, DayReorderDto, DayTransportDto, DayUpdateDto } from './days.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
@@ -32,7 +33,7 @@ import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.g
|
||||
// go through that method, and this keeps the two from drifting.
|
||||
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
||||
export class DaysController {
|
||||
constructor(private readonly days: DaysService) {}
|
||||
constructor(private readonly days: DaysService, private readonly removal: DayRemovalService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
@@ -131,8 +132,21 @@ export class DaysController {
|
||||
if (!this.days.getDay(id, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
this.days.remove(id);
|
||||
this.days.broadcast(tripId, 'day:deleted', { dayId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
let removal: DayRemoval;
|
||||
try {
|
||||
removal = this.removal.remove(tripId, id, { userId: user.id, socketId });
|
||||
} catch (err) {
|
||||
// The last day of a trip stays; nothing was written.
|
||||
if (err instanceof DayDeleteError) throw new HttpException({ error: err.message }, 400);
|
||||
throw err;
|
||||
}
|
||||
this.removal.announce(tripId, removal, {
|
||||
all: (event, payload) => this.days.broadcast(tripId, event, payload, undefined),
|
||||
others: (event, payload) => this.days.broadcast(tripId, event, payload, socketId),
|
||||
socketId,
|
||||
});
|
||||
// `trip` is additive: the socket echo skips this tab, and the trip header
|
||||
// shows the day count and, when the last date went, the new end date.
|
||||
return { success: true, trip: removal.trip };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from '@trek/shared';
|
||||
import type { DayCreateRequest, DayReorderRequest, DayUpdateRequest } from '@trek/shared';
|
||||
import { DaysService, DayReorderError } from './days.service';
|
||||
import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service';
|
||||
import type { MirrorSender } from '../accommodations/accommodations.service';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
const n = Number(Array.isArray(value) ? value[0] : value);
|
||||
@@ -41,6 +43,7 @@ export class DaysMcp {
|
||||
private readonly days: DaysService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
private readonly removal: DayRemovalService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
@@ -144,7 +147,7 @@ export class DaysMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'delete_day',
|
||||
description: 'Delete a day from a trip.',
|
||||
description: 'Delete a day from a trip. The places planned on it stay in the place list of the trip; its notes, title and description are deleted; bookings on it stay on the trip without a day. A stay that checks in or out on the day is cancelled together with its booking and the expense of that booking, while a stay that only runs across the day is kept. The later days move up one place. On a dated trip the dates stay on their positions, so every later day and the bookings on it move one date earlier; a day without a date then takes the last date, and when there is none the trip ends one day earlier. The last day of a trip cannot be deleted.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
@@ -157,10 +160,18 @@ export class DaysMcp {
|
||||
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.');
|
||||
this.days.remove(dayId);
|
||||
// REST parity shape ({ dayId }) — the client reads payload.dayId, so the { id }
|
||||
// variant never removed the day from collaborator screens.
|
||||
this.guards.safeBroadcast(tripId, 'day:deleted', { dayId });
|
||||
let removal: DayRemoval;
|
||||
try {
|
||||
removal = this.removal.remove(tripId, dayId, { userId: ctx.userId });
|
||||
} catch (err) {
|
||||
// The last day stays, and the caller can act on the sentence.
|
||||
if (err instanceof DayDeleteError) return errorResult(err.message);
|
||||
throw err;
|
||||
}
|
||||
// The same fan-out as REST, day:deleted ({ dayId }, the shape the client reads)
|
||||
// first. A tool has no socket of its own, so every screen hears all of it.
|
||||
const send: MirrorSender = (event, payload) => this.guards.safeBroadcast(tripId, event, payload as Record<string, unknown>);
|
||||
this.removal.announce(tripId, removal, { all: send, others: send });
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { DaysController } from './days.controller';
|
||||
import { DaysService } from './days.service';
|
||||
import { DaysMcp } from './days.mcp';
|
||||
import { DaysRpc } from './days.rpc';
|
||||
import { DayRemovalService } from './day-removal.service';
|
||||
import { AccommodationsDomainModule } from '../accommodations/accommodations-domain.module';
|
||||
import { AssignmentsDomainModule } from '../assignments/assignments-domain.module';
|
||||
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
|
||||
import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
@@ -19,11 +22,19 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
*
|
||||
* Day notes used to live here too, with their own full file set; they are their
|
||||
* own domain now (day-notes/).
|
||||
*
|
||||
* Deleting a day cancels the stays that check in or out on it and lets the
|
||||
* journey catch up, so DayRemovalService needs the accommodations and
|
||||
* assignments services. Both domain modules are leaves (neither reaches days or
|
||||
* places), and PlacesModule already brings them in, so the edge adds no cycle.
|
||||
*/
|
||||
@Module({
|
||||
imports: [McpSharedModule, PermissionsModule, QueryHelpersModule, PlacesModule, AuthModule, RealtimeModule, PluginGuardsModule],
|
||||
imports: [
|
||||
McpSharedModule, PermissionsModule, QueryHelpersModule, PlacesModule, AuthModule, RealtimeModule, PluginGuardsModule,
|
||||
AccommodationsDomainModule, AssignmentsDomainModule,
|
||||
],
|
||||
controllers: [DaysController],
|
||||
providers: [DaysService, DaysMcp, DaysRpc],
|
||||
providers: [DaysService, DayRemovalService, DaysMcp, DaysRpc],
|
||||
exports: [DaysService],
|
||||
})
|
||||
export class DaysModule {}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { num, schemaMessage } from '../plugins/host/rpc-params';
|
||||
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DaysService } from './days.service';
|
||||
import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service';
|
||||
import type { MirrorSender } from '../accommodations/accommodations.service';
|
||||
|
||||
const DAY_EDIT_ACTION = 'day_edit';
|
||||
|
||||
@@ -21,6 +23,7 @@ export class DaysRpc {
|
||||
private readonly days: DaysService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly guards: PluginGuards,
|
||||
private readonly removal: DayRemovalService,
|
||||
) {}
|
||||
|
||||
@PluginMethod('days.create', { permission: 'db:write:days' })
|
||||
@@ -59,8 +62,15 @@ export class DaysRpc {
|
||||
const actor = this.guards.requireActor(ctx, 'day');
|
||||
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
|
||||
if (!this.days.getDay(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
|
||||
this.days.remove(dayId);
|
||||
this.realtime.broadcast(tripId, 'day:deleted', { dayId });
|
||||
let removal: DayRemoval;
|
||||
try {
|
||||
removal = this.removal.remove(tripId, dayId, { userId: actor });
|
||||
} catch (err) {
|
||||
if (err instanceof DayDeleteError) throw new BadParams(err.message);
|
||||
throw err;
|
||||
}
|
||||
const send: MirrorSender = (event, payload) => this.realtime.broadcast(tripId, event, payload);
|
||||
this.removal.announce(tripId, removal, { all: send, others: send });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
|
||||
import { carryVias, locatedStopIds, reseatOwnStop } from '../accommodations/night-seat';
|
||||
import { formatAssignmentWithPlace } from '../common/rowShape';
|
||||
import { TRIP_SELECT } from '../trips/trip-select';
|
||||
import type { AssignmentRow, Day, DayNote, User } from '../../types';
|
||||
|
||||
type Trip = TripAccess;
|
||||
@@ -259,8 +260,13 @@ export class DaysService {
|
||||
return { ...updatedDay, assignments: this.getAssignmentsForDay(id) };
|
||||
}
|
||||
|
||||
remove(id: string | number): void {
|
||||
this.db.run('DELETE FROM days WHERE id = ?', id);
|
||||
/**
|
||||
* The trip in list shape, as the trip routes answer it, for a day write that
|
||||
* changed the trip itself (its end date, its day count). Read through the leaf
|
||||
* query rather than TripsService, which imports this class.
|
||||
*/
|
||||
getTripForViewer(tripId: string | number, userId: number): unknown {
|
||||
return this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The trip row in list shape, and the one field that must never leave with it.
|
||||
*
|
||||
* A leaf on purpose: it imports nothing. TripsService imports DaysService, so a
|
||||
* day write that has to hand its collaborators the changed trip (a day deleted
|
||||
* off the end of a dated trip shortens it) cannot import trips.service.ts
|
||||
* without closing a module cycle. Reading the same query from here keeps the
|
||||
* two surfaces from drifting apart instead of copying it into the days domain.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strips `feed_token` from a trip row on its way out.
|
||||
*
|
||||
* The column is the sole credential for the anonymous /api/feed/trip/:token.ics
|
||||
* route, and `SELECT t.*` hands it to every reader of the trip. Gating the
|
||||
* token endpoint on `share_manage` means nothing while any member can read the
|
||||
* same value out of the trip payload, so the two go together. No TREK client
|
||||
* reads the field (it is absent from client/ and shared/ entirely).
|
||||
*/
|
||||
export function withoutFeedToken<T>(row: T): T {
|
||||
if (row && typeof row === 'object') delete (row as Record<string, unknown>).feed_token;
|
||||
return row;
|
||||
}
|
||||
|
||||
// `NULL AS feed_token` after `t.*` rather than an explicit column list: the
|
||||
// duplicate name wins in the row object, so the credential is blanked once here
|
||||
// instead of at each of the nine call sites, and the next migration that adds a
|
||||
// column does not have to remember to extend a hand-maintained list.
|
||||
export const TRIP_SELECT = `
|
||||
SELECT t.*,
|
||||
NULL AS feed_token,
|
||||
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
|
||||
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
|
||||
CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
|
||||
u.username as owner_username,
|
||||
(SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
|
||||
FROM trips t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
`;
|
||||
@@ -13,6 +13,7 @@ import { UnsplashService } from '../unsplash/unsplash.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
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;
|
||||
|
||||
@@ -29,35 +30,10 @@ function assertTripSpan(startDate: string, endDate: string) {
|
||||
if (span > MAX_TRIP_DAYS) throw new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips `feed_token` from a trip row on its way out.
|
||||
*
|
||||
* The column is the sole credential for the anonymous /api/feed/trip/:token.ics
|
||||
* route, and `SELECT t.*` hands it to every reader of the trip. Gating the
|
||||
* token endpoint on `share_manage` means nothing while any member can read the
|
||||
* same value out of the trip payload, so the two go together. No TREK client
|
||||
* reads the field (it is absent from client/ and shared/ entirely).
|
||||
*/
|
||||
export function withoutFeedToken<T>(row: T): T {
|
||||
if (row && typeof row === 'object') delete (row as Record<string, unknown>).feed_token;
|
||||
return row;
|
||||
}
|
||||
|
||||
// `NULL AS feed_token` after `t.*` rather than an explicit column list: the
|
||||
// duplicate name wins in the row object, so the credential is blanked once here
|
||||
// instead of at each of the nine call sites, and the next migration that adds a
|
||||
// column does not have to remember to extend a hand-maintained list.
|
||||
export const TRIP_SELECT = `
|
||||
SELECT t.*,
|
||||
NULL AS feed_token,
|
||||
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
|
||||
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
|
||||
CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
|
||||
u.username as owner_username,
|
||||
(SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
|
||||
FROM trips t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
`;
|
||||
// The list-shape query and the feed-token scrub live in a leaf file so the days
|
||||
// domain can read the same trip row without importing this module (which
|
||||
// imports DaysService). Re-exported here so every existing importer is unchanged.
|
||||
export { TRIP_SELECT, withoutFeedToken } from './trip-select';
|
||||
|
||||
interface CreateTripData {
|
||||
title: string;
|
||||
|
||||
@@ -23,7 +23,8 @@ const { db } = vi.hoisted(() => {
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
|
||||
avatar TEXT);`);
|
||||
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, end_date TEXT);');
|
||||
// start_date + updated_at: deleting a dated day can end the trip a day earlier.
|
||||
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, start_date TEXT, end_date TEXT, feed_token TEXT, updated_at TEXT);');
|
||||
tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);');
|
||||
// The tables DaysService really queries (real SQL, no service mock).
|
||||
tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
|
||||
@@ -54,6 +55,9 @@ const { db } = vi.hoisted(() => {
|
||||
metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
||||
tmp.exec(`CREATE TABLE reservation_endpoints (id INTEGER PRIMARY KEY AUTOINCREMENT, reservation_id INTEGER NOT NULL,
|
||||
local_date TEXT);`);
|
||||
// A deleted day moves the road trip boundaries after it up with their days.
|
||||
tmp.exec(`CREATE TABLE roadtrip_day_boundaries (trip_id INTEGER NOT NULL, day_number INTEGER NOT NULL CHECK (day_number >= 1),
|
||||
from_assignment_id INTEGER NOT NULL, to_assignment_id INTEGER, fraction REAL NOT NULL, PRIMARY KEY (trip_id, day_number));`);
|
||||
// StorageRegistryService (behind StorageModule, now in this module chain) reads
|
||||
// this at onModuleInit.
|
||||
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
||||
@@ -197,13 +201,38 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', (
|
||||
expect(bad.body).toEqual({ error: 'orderedIds must be a permutation of the trip day ids.' });
|
||||
});
|
||||
|
||||
it('200 delete day removes the row', async () => {
|
||||
it('200 delete day removes the row, closes the gap and answers with the trip', async () => {
|
||||
db.prepare('INSERT INTO trips (id, user_id, title) VALUES (7, 1, ?)').run('Delete');
|
||||
const id = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (7, 1)').run().lastInsertRowid);
|
||||
const kept = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (7, 2)').run().lastInsertRowid);
|
||||
const res = await request(server).delete(`/api/trips/7/days/${id}`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(res.body).toMatchObject({ success: true, trip: { id: 7, day_count: 1, is_owner: 1 } });
|
||||
expect(db.prepare('SELECT * FROM days WHERE id = ?').get(id)).toBeUndefined();
|
||||
expect(db.prepare('SELECT day_number FROM days WHERE id = ?').get(kept)).toEqual({ day_number: 1 });
|
||||
});
|
||||
|
||||
it('200 delete a dated day with no spare day ends the trip a day earlier', async () => {
|
||||
db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (8, 1, ?, ?, ?)').run('Shrink', '2026-09-01', '2026-09-02');
|
||||
const first = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (8, 1, ?)').run('2026-09-01').lastInsertRowid);
|
||||
const second = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (8, 2, ?)').run('2026-09-02').lastInsertRowid);
|
||||
const res = await request(server).delete(`/api/trips/8/days/${first}`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip).toMatchObject({ id: 8, end_date: '2026-09-01', day_count: 1 });
|
||||
expect(db.prepare('SELECT day_number, date FROM days WHERE id = ?').get(second)).toEqual({ day_number: 1, date: '2026-09-01' });
|
||||
});
|
||||
|
||||
it('400 delete the last day of a trip, 403 without day_edit', async () => {
|
||||
db.prepare('INSERT INTO trips (id, user_id, title) VALUES (9, 1, ?)').run('Last');
|
||||
const only = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (9, 1)').run().lastInsertRowid);
|
||||
const last = await request(server).delete(`/api/trips/9/days/${only}`).set('Cookie', sessionCookie(1));
|
||||
expect(last.status).toBe(400);
|
||||
expect(last.body).toEqual({ error: 'A trip needs at least one day.' });
|
||||
expect(db.prepare('SELECT id FROM days WHERE id = ?').get(only)).toEqual({ id: only });
|
||||
checkPermission.mockReturnValue(false);
|
||||
const forbidden = await request(server).delete(`/api/trips/9/days/${only}`).set('Cookie', sessionCookie(1));
|
||||
expect(forbidden.status).toBe(403);
|
||||
expect(forbidden.body).toEqual({ error: 'No permission' });
|
||||
});
|
||||
|
||||
it('201 create note (real insert: trim, empty-string coercions), 400 on over-long text (before access)', async () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { DayNotesMcp } from '../../src/nest/day-notes/day-notes.mcp';
|
||||
import { DayNotesService } from '../../src/nest/day-notes/day-notes.service';
|
||||
import { DaysMcp } from '../../src/nest/days/days.mcp';
|
||||
import { DaysService } from '../../src/nest/days/days.service';
|
||||
import { DayRemovalService } from '../../src/nest/days/day-removal.service';
|
||||
import { MapsMcp } from '../../src/nest/maps/maps.mcp';
|
||||
import { WeatherMcp } from '../../src/nest/weather/weather.mcp';
|
||||
import { WeatherService } from '../../src/nest/weather/weather.service';
|
||||
@@ -177,6 +178,8 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
);
|
||||
// Built after it: a hotel booking writes the stay's day stop through this one.
|
||||
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadRepository(dbService), accommodationsService);
|
||||
// Deleting a day cancels the stays on it through the same accommodations service.
|
||||
const dayRemovalService = new DayRemovalService(dbService, daysService, accommodationsService, assignmentsService);
|
||||
const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService, budgetService), permissionsService, realtimeService, notificationsStub());
|
||||
const tripsService = new TripsService(
|
||||
dbService,
|
||||
@@ -223,7 +226,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), new TripMembershipService(dbService), addonsService, guards),
|
||||
new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards),
|
||||
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards),
|
||||
new DaysMcp(daysService, authService, guards),
|
||||
new DaysMcp(daysService, authService, guards, dayRemovalService),
|
||||
new RoadtripMcp(new RoadtripService(dbService, realtimeService), dbService, guards, authService, addonsService),
|
||||
new RoadtripPreferencesMcp(new RoadtripPreferencesService(dbService, realtimeService), authService, addonsService, dbService, guards),
|
||||
new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage), authService, guards),
|
||||
|
||||
@@ -21,6 +21,7 @@ import { TodoService } from '../../src/nest/todo/todo.service';
|
||||
import { PackingService } from '../../src/nest/packing/packing.service';
|
||||
import { DayNotesService } from '../../src/nest/day-notes/day-notes.service';
|
||||
import { DaysService } from '../../src/nest/days/days.service';
|
||||
import { DayRemovalService } from '../../src/nest/days/day-removal.service';
|
||||
import { AssignmentsService } from '../../src/nest/assignments/assignments.service';
|
||||
import { LlmConfigResolver } from '../../src/nest/llm-parse/llm-config.resolver';
|
||||
import { SettingsService } from '../../src/nest/settings/settings.service';
|
||||
@@ -127,7 +128,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
|
||||
new PackingRpc(packing, realtime, guards),
|
||||
new FilesRpc(files, realtime, dbs, guards, generalStorage),
|
||||
new PlacesRpc(places, journey, realtime, guards),
|
||||
new DaysRpc(days, realtime, guards),
|
||||
new DaysRpc(days, realtime, guards, new DayRemovalService(dbs, days, accommodations, assignments)),
|
||||
new AccommodationsRpc(accommodations, realtime, guards),
|
||||
new ItineraryRpc(assignments, realtime, guards),
|
||||
new TripsRpc(trips, reservations, days, membership, dbs, realtime, guards, accommodations, members),
|
||||
|
||||
@@ -69,7 +69,7 @@ export function allRpcControllers(): object[] {
|
||||
new PackingRpc(anyService(), anyService(), anyService()),
|
||||
new FilesRpc(anyService(), anyService(), anyService(), anyService(), anyService()),
|
||||
new PlacesRpc(anyService(), anyService(), anyService(), anyService()),
|
||||
new DaysRpc(anyService(), anyService(), anyService()),
|
||||
new DaysRpc(anyService(), anyService(), anyService(), anyService()),
|
||||
new ItineraryRpc(anyService(), anyService(), anyService()),
|
||||
new TripsRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()),
|
||||
new CostsRpc(anyService(), anyService(), anyService(), anyService(), anyService()),
|
||||
|
||||
@@ -42,6 +42,7 @@ const RESET_TABLES = [
|
||||
// one case into the next.
|
||||
'roadtrip_day_tracks',
|
||||
'roadtrip_vias',
|
||||
'roadtrip_day_boundaries',
|
||||
// Dawarich (#2279). The suggestions reference places and bucket_list, so they
|
||||
// are cleared before both; the connection row hangs off the user.
|
||||
'dawarich_visit_suggestions',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Days & Accommodations API integration tests.
|
||||
* Covers DAY-001 through DAY-006 and ACCOM-001 through ACCOM-003.
|
||||
* Covers DAY-001 through DAY-010 and ACCOM-001 through ACCOM-006.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -267,6 +267,7 @@ describe('Delete day', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
const day = createDay(testDb, trip.id);
|
||||
createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/${day.id}`)
|
||||
@@ -290,6 +291,74 @@ describe('Delete day', () => {
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('DAY-007: the days after a deleted one close up, and the answer carries the trip with its new day count', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
const [a, b, c] = [createDay(testDb, trip.id), createDay(testDb, trip.id), createDay(testDb, trip.id)];
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/${b.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ success: true, trip: { id: trip.id, day_count: 2 } });
|
||||
const list = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
|
||||
expect(list.body.days.map((d: { id: number; day_number: number }) => [d.id, d.day_number])).toEqual([[a.id, 1], [c.id, 2]]);
|
||||
});
|
||||
|
||||
it('DAY-008: deleting a dated day with no day left to take the last date ends the trip a day earlier', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-08-01', end_date: '2026-08-03' });
|
||||
const first = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number LIMIT 1').get(trip.id) as { id: number };
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/${first.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip).toMatchObject({ start_date: '2026-08-01', end_date: '2026-08-02' });
|
||||
const reread = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
|
||||
expect(reread.body.trip).toMatchObject({ start_date: '2026-08-01', end_date: '2026-08-02' });
|
||||
const list = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
|
||||
expect(list.body.days.map((d: { date: string }) => d.date)).toEqual(['2026-08-01', '2026-08-02']);
|
||||
});
|
||||
|
||||
it('DAY-009: the last day of a trip is refused with 400 and stays', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/${day.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'A trip needs at least one day.' });
|
||||
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('DAY-010: a member without day_edit gets 403 and the day stays', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
createDay(testDb, trip.id);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_day_edit', 'trip_owner')").run();
|
||||
const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache');
|
||||
invalidatePermissionsCache();
|
||||
try {
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/${day.id}`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
expect(res.status).toBe(403);
|
||||
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
|
||||
} finally {
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run();
|
||||
invalidatePermissionsCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,6 +15,7 @@ import { DaysModule } from '../../../src/nest/days/days.module';
|
||||
import { ItineraryRpc } from '../../../src/nest/assignments/itinerary.rpc';
|
||||
import { AssignmentsModule } from '../../../src/nest/assignments/assignments.module';
|
||||
import type { DaysService } from '../../../src/nest/days/days.service';
|
||||
import { DayDeleteError, type DayRemoval, type DayRemovalService } from '../../../src/nest/days/day-removal.service';
|
||||
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
||||
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
@@ -31,8 +32,20 @@ function build(opts: { canEdit?: boolean } = {}) {
|
||||
create: vi.fn((tripId: number, date?: string) => ({ id: 20, trip_id: tripId, date })),
|
||||
getDay: vi.fn((dayId: number, tripId: number) => (dayId === 3 && tripId === 1 ? { id: 3 } : undefined)),
|
||||
update: vi.fn((id: number) => ({ id })),
|
||||
remove: vi.fn(),
|
||||
} as unknown as DaysService & Record<string, ReturnType<typeof vi.fn>>;
|
||||
const removed: DayRemoval = {
|
||||
dayId: 3, orderedIds: [4], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [],
|
||||
boundaries: null, endDate: null, trip: { id: 1 },
|
||||
};
|
||||
// announce() is the removal service's own fan-out (DAY-DEL-014); the stub sends
|
||||
// one event through each sender so the case can see where they lead.
|
||||
const removal = {
|
||||
remove: vi.fn(() => removed),
|
||||
announce: vi.fn((_tripId: number, r: DayRemoval, senders: Parameters<DayRemovalService['announce']>[2]) => {
|
||||
senders.others('day:deleted', { dayId: r.dayId });
|
||||
senders.all('day:reordered', { orderedIds: r.orderedIds });
|
||||
}),
|
||||
} as unknown as DayRemovalService;
|
||||
const assignments = {
|
||||
dayExists: vi.fn((dayId: number, tripId: number) => dayId === 3 && tripId === 1),
|
||||
placeExists: vi.fn((placeId: number, tripId: number) => placeId === 7 && tripId === 1),
|
||||
@@ -50,9 +63,9 @@ function build(opts: { canEdit?: boolean } = {}) {
|
||||
{ checkPermission: vi.fn(() => opts.canEdit ?? true) } as unknown as PermissionsService,
|
||||
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
|
||||
);
|
||||
const registry = createTestPluginRegistry([new DaysRpc(days, realtime, guards), new ItineraryRpc(assignments, realtime, guards)]);
|
||||
const registry = createTestPluginRegistry([new DaysRpc(days, realtime, guards, removal), new ItineraryRpc(assignments, realtime, guards)]);
|
||||
const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants), makeDeps(), registry);
|
||||
return { days, assignments, realtime, host };
|
||||
return { days, assignments, realtime, host, removal, removed };
|
||||
}
|
||||
|
||||
describe('DaysRpc', () => {
|
||||
@@ -72,7 +85,7 @@ describe('DaysRpc', () => {
|
||||
.toBe('no day 404 on trip 1');
|
||||
expect(((await host.dispatch(req('days.delete', { tripId: 1, dayId: 404 }), 42)) as RpcError).error.message)
|
||||
.toBe('no day 404 on trip 1');
|
||||
expect(f.days.remove).not.toHaveBeenCalled();
|
||||
expect(f.removal.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DAYS-RPC-003 every write broadcasts its event', async () => {
|
||||
@@ -81,12 +94,42 @@ describe('DaysRpc', () => {
|
||||
await host.dispatch(req('days.create', { tripId: 1, input: {} }), 42);
|
||||
await host.dispatch(req('days.update', { tripId: 1, dayId: 3, input: { notes: 'x' } }), 42);
|
||||
await host.dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42);
|
||||
expect(f.realtime.broadcast.mock.calls.map((c) => c[1])).toEqual(['day:created', 'day:updated', 'day:deleted']);
|
||||
expect(f.realtime.broadcast.mock.calls.map((c) => c[1])).toEqual(['day:created', 'day:updated', 'day:deleted', 'day:reordered']);
|
||||
});
|
||||
|
||||
it('DAYS-RPC-004 the class is listed in its module providers', () => {
|
||||
expectRegisteredProvider(DaysModule, DaysRpc);
|
||||
});
|
||||
|
||||
it('DAYS-RPC-005 the last day of a trip is BAD_PARAMS with the sentence REST and MCP give, and nothing is announced', async () => {
|
||||
const f = build();
|
||||
vi.mocked(f.removal.remove).mockImplementation(() => { throw new DayDeleteError('A trip needs at least one day.'); });
|
||||
const res = (await f.host('db:write:days').dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42)) as RpcError;
|
||||
expect(res.error).toMatchObject({ code: 'BAD_PARAMS', message: 'A trip needs at least one day.' });
|
||||
expect(f.removal.announce).not.toHaveBeenCalled();
|
||||
expect(f.realtime.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DAYS-RPC-006 a delete runs as the bound user and fans out through the shared announce, to every socket', async () => {
|
||||
const f = build();
|
||||
const res = await f.host('db:write:days').dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42);
|
||||
expect(res).toMatchObject({ ok: true, result: { deleted: true } });
|
||||
expect(f.removal.remove).toHaveBeenCalledWith(1, 3, { userId: 42 });
|
||||
const senders = vi.mocked(f.removal.announce).mock.calls[0][2];
|
||||
// A plugin has no socket of its own, so nobody is left out.
|
||||
expect(senders.all).toBe(senders.others);
|
||||
expect(f.realtime.broadcast.mock.calls).toEqual([
|
||||
[1, 'day:deleted', { dayId: 3 }],
|
||||
[1, 'day:reordered', { orderedIds: [4] }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('DAYS-RPC-006b anything but the last-day refusal is not dressed up as bad params', async () => {
|
||||
const f = build();
|
||||
vi.mocked(f.removal.remove).mockImplementation(() => { throw new Error('db is down'); });
|
||||
const res = (await f.host('db:write:days').dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42)) as RpcError;
|
||||
expect(res.error.code).not.toBe('BAD_PARAMS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ItineraryRpc', () => {
|
||||
|
||||
@@ -51,7 +51,9 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createDayAccommodation } from '../../helpers/factories';
|
||||
import { createUser, createTrip, createDay, createPlace, createDayAccommodation, addTripMember } from '../../helpers/factories';
|
||||
import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache';
|
||||
import { DayRemovalService } from '../../../src/nest/days/day-removal.service';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -155,19 +157,94 @@ describe('Tool: create_day', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_day', () => {
|
||||
it('deletes a day and broadcasts', async () => {
|
||||
const dayRows = (tripId: number) =>
|
||||
testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number; day_number: number; date: string | null }[];
|
||||
|
||||
it('deletes a day the way REST does: the gap closes, the dates stay on their slots, and every screen hears it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-01-01', end_date: '2026-01-03' });
|
||||
const [d1, d2, d3] = dayRows(trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_day',
|
||||
arguments: { tripId: trip.id, dayId: d2.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(d2.id)).toBeUndefined();
|
||||
expect(dayRows(trip.id)).toEqual([
|
||||
{ id: d1.id, day_number: 1, date: '2026-01-01' },
|
||||
{ id: d3.id, day_number: 2, date: '2026-01-02' },
|
||||
]);
|
||||
expect(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ end_date: '2026-01-02' });
|
||||
const events = broadcastMock.mock.calls.map(c => c[1]);
|
||||
expect(events).toEqual(['day:deleted', 'day:reordered', 'trip:updated']);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:deleted', expect.objectContaining({ dayId: d2.id }));
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:reordered', expect.objectContaining({ orderedIds: [d1.id, d3.id] }));
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'trip:updated', expect.objectContaining({ trip: expect.objectContaining({ id: trip.id, end_date: '2026-01-02' }) }));
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels a stay checking in on the day with its booking, and says so', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const d1 = createDay(testDb, trip.id);
|
||||
const d2 = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Harbour Hotel' });
|
||||
const stay = createDayAccommodation(testDb, trip.id, place.id, d2.id, d2.id);
|
||||
const reservationId = Number(testDb.prepare(
|
||||
"INSERT INTO reservations (trip_id, day_id, title, type, accommodation_id) VALUES (?, ?, 'Harbour Hotel', 'hotel', ?)",
|
||||
).run(trip.id, d2.id, stay.id).lastInsertRowid);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: d2.id } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(stay.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservationId)).toBeUndefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:deleted', expect.objectContaining({ reservationId }));
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:deleted', expect.objectContaining({ accommodationId: stay.id }));
|
||||
expect(dayRows(trip.id).map(r => r.id)).toEqual([d1.id]);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses the last day of a trip with the REST sentence and leaves it in place', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_day',
|
||||
arguments: { tripId: trip.id, dayId: day.id },
|
||||
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('A trip needs at least one day.');
|
||||
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
|
||||
expect(broadcastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an unexpected failure surface as one, not as the last-day refusal', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
createDay(testDb, trip.id);
|
||||
const boom = vi.spyOn(DayRemovalService.prototype, 'remove').mockImplementation(() => { throw new Error('disk full'); });
|
||||
try {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).not.toContain('A trip needs at least one day.');
|
||||
expect(broadcastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:deleted', expect.objectContaining({ dayId: day.id }));
|
||||
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeUndefined();
|
||||
} finally {
|
||||
boom.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a day that is not on the trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: 999999 } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('Day not found.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,6 +258,40 @@ describe('Tool: delete_day', () => {
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a member without day_edit, the same right the REST route asks for', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
createDay(testDb, trip.id);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_day_edit', 'trip_owner')").run();
|
||||
invalidatePermissionsCache();
|
||||
try {
|
||||
await withHarness(member.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
|
||||
});
|
||||
} finally {
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run();
|
||||
invalidatePermissionsCache();
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* DayRemovalService: deleting one day, the way the reorder dialog, the MCP tool
|
||||
* and the plugin RPC all do it. DAY-DEL-001 to DAY-DEL-018.
|
||||
*
|
||||
* Real in-memory SQLite with the real accommodations and assignments services,
|
||||
* so the foreign key cascades and the stay cancellation are the ones production runs.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: number | string, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: number | string, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||||
import { DaysService } from '../../../src/nest/days/days.service';
|
||||
import { DayRemovalService, DayDeleteError, LAST_DAY_MESSAGE, type DayRemoval } from '../../../src/nest/days/day-removal.service';
|
||||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
||||
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
||||
import { AccommodationsService, type MirrorSender } from '../../../src/nest/accommodations/accommodations.service';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const permissions = new PermissionsService(dbs);
|
||||
const realtime = new RealtimeService();
|
||||
const queryHelpers = new QueryHelpersService(dbs);
|
||||
const days = new DaysService(dbs, permissions, realtime, queryHelpers);
|
||||
const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs));
|
||||
const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers, journey);
|
||||
const accommodations = new AccommodationsService(dbs, permissions, realtime, assignments);
|
||||
const removal = new DayRemovalService(dbs, days, accommodations, assignments);
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
type Row = { id: number; day_number: number; date: string | null };
|
||||
|
||||
const dayRows = (tripId: number): Row[] =>
|
||||
testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as Row[];
|
||||
|
||||
const range = (tripId: number) =>
|
||||
testDb.prepare('SELECT start_date, end_date FROM trips WHERE id = ?').get(tripId) as { start_date: string | null; end_date: string | null };
|
||||
|
||||
const reservationRow = (id: number) =>
|
||||
testDb.prepare('SELECT day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE id = ?').get(id) as
|
||||
{ day_id: number | null; end_day_id: number | null; reservation_time: string | null; reservation_end_time: string | null } | undefined;
|
||||
|
||||
function booking(tripId: number, dayId: number | null, time: string | null, extra: { endDayId?: number; endTime?: string } = {}): number {
|
||||
return Number(testDb.prepare(
|
||||
'INSERT INTO reservations (trip_id, day_id, end_day_id, title, type, reservation_time, reservation_end_time) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(tripId, dayId, extra.endDayId ?? null, 'Booking', 'restaurant', time, extra.endTime ?? null).lastInsertRowid);
|
||||
}
|
||||
|
||||
/** A dated trip whose days the factory generated, one per date. */
|
||||
function datedTrip(start: string, end: string) {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: start, end_date: end });
|
||||
return { user, trip, rows: dayRows(trip.id) };
|
||||
}
|
||||
|
||||
/** A booked night: the stay, its hotel booking, the stop on its check-in day and an expense. */
|
||||
function bookedNight(tripId: number, startDayId: number, endDayId: number) {
|
||||
const place = createPlace(testDb, tripId, { name: 'Harbour Hotel' });
|
||||
const { accommodation } = accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id: startDayId, end_day_id: endDayId });
|
||||
const stayId = (accommodation as { id: number }).id;
|
||||
const reservationId = (testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(stayId) as { id: number }).id;
|
||||
const budgetId = Number(testDb.prepare(
|
||||
'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(tripId, 'Harbour Hotel', 'Accommodation', 240, reservationId).lastInsertRowid);
|
||||
const stop = testDb.prepare('SELECT id, day_id FROM day_assignments WHERE accommodation_id = ?').get(stayId) as { id: number; day_id: number };
|
||||
return { place, stayId, reservationId, budgetId, stop };
|
||||
}
|
||||
|
||||
function boundary(tripId: number, dayNumber: number, fromAssignmentId: number) {
|
||||
testDb.prepare(
|
||||
'INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, ?, ?, NULL, 1)',
|
||||
).run(tripId, dayNumber, fromAssignmentId);
|
||||
}
|
||||
|
||||
describe('DayRemovalService.remove', () => {
|
||||
it('DAY-DEL-001 an undated day only closes the gap in the numbering', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const dateless = createTrip(testDb, user.id);
|
||||
const [a, b, c] = [createDay(testDb, dateless.id), createDay(testDb, dateless.id), createDay(testDb, dateless.id)];
|
||||
|
||||
const result = removal.remove(dateless.id, b.id, { userId: user.id });
|
||||
|
||||
expect(dayRows(dateless.id)).toEqual([
|
||||
{ id: a.id, day_number: 1, date: null },
|
||||
{ id: c.id, day_number: 2, date: null },
|
||||
]);
|
||||
expect(result).toMatchObject({ dayId: b.id, orderedIds: [a.id, c.id], endDate: null, boundaries: null, stayIds: [] });
|
||||
|
||||
// The extra day of a dated trip, the case the dialog was asked for: no date moves.
|
||||
const dated = datedTrip('2026-03-01', '2026-03-02');
|
||||
const spare = createDay(testDb, dated.trip.id);
|
||||
const dinner = booking(dated.trip.id, dated.rows[1].id, '2026-03-02T19:00');
|
||||
removal.remove(dated.trip.id, spare.id, { userId: dated.user.id });
|
||||
expect(dayRows(dated.trip.id)).toEqual(dated.rows);
|
||||
expect(range(dated.trip.id)).toEqual({ start_date: '2026-03-01', end_date: '2026-03-02' });
|
||||
expect(reservationRow(dinner)?.reservation_time).toBe('2026-03-02T19:00');
|
||||
});
|
||||
|
||||
it('DAY-DEL-002 a dated day with a spare day after it: the dates stay on their slots and the bookings move along', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
|
||||
const [d1, d2, d3] = rows;
|
||||
const spare = createDay(testDb, trip.id);
|
||||
const flight = booking(trip.id, d3.id, '2026-01-03T10:00', { endDayId: d3.id, endTime: '2026-01-03T12:00' });
|
||||
const leg = Number(testDb.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, local_date) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(flight, 'from', 0, 'HEL', 60.31, 24.96, '2026-01-03').lastInsertRowid);
|
||||
|
||||
const result = removal.remove(trip.id, d2.id, { userId: user.id });
|
||||
|
||||
expect(dayRows(trip.id)).toEqual([
|
||||
{ id: d1.id, day_number: 1, date: '2026-01-01' },
|
||||
{ id: d3.id, day_number: 2, date: '2026-01-02' },
|
||||
{ id: spare.id, day_number: 3, date: '2026-01-03' },
|
||||
]);
|
||||
expect(reservationRow(flight)).toMatchObject({ reservation_time: '2026-01-02T10:00', reservation_end_time: '2026-01-02T12:00' });
|
||||
expect(testDb.prepare('SELECT local_date FROM reservation_endpoints WHERE id = ?').get(leg)).toEqual({ local_date: '2026-01-02' });
|
||||
// The spare day took the last date, so the trip keeps its range.
|
||||
expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' });
|
||||
expect(result.endDate).toBeNull();
|
||||
});
|
||||
|
||||
it('DAY-DEL-003 a dated day with no spare day left: the last date goes and the trip ends a day earlier', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
|
||||
const [d1, d2, d3] = rows;
|
||||
|
||||
const result = removal.remove(trip.id, d1.id, { userId: user.id });
|
||||
|
||||
expect(dayRows(trip.id)).toEqual([
|
||||
{ id: d2.id, day_number: 1, date: '2026-01-01' },
|
||||
{ id: d3.id, day_number: 2, date: '2026-01-02' },
|
||||
]);
|
||||
expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-02' });
|
||||
expect(result.endDate).toBe('2026-01-02');
|
||||
// The trip in list shape for the trip header, without its feed credential.
|
||||
expect(result.trip).toMatchObject({ id: trip.id, end_date: '2026-01-02', day_count: 2, is_owner: 1, feed_token: null });
|
||||
});
|
||||
|
||||
it('DAY-DEL-004 the last day of a trip stays, and nothing is written', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-05-01', '2026-05-01');
|
||||
const night = bookedNight(trip.id, rows[0].id, rows[0].id);
|
||||
|
||||
expect(() => removal.remove(trip.id, rows[0].id, { userId: user.id })).toThrow(new DayDeleteError(LAST_DAY_MESSAGE));
|
||||
expect(dayRows(trip.id)).toEqual(rows);
|
||||
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeDefined();
|
||||
expect(reservationRow(night.reservationId)).toBeDefined();
|
||||
});
|
||||
|
||||
it('DAY-DEL-005 a stay checking in on the day is cancelled with its booking and that booking\'s expense', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
|
||||
const night = bookedNight(trip.id, rows[1].id, rows[2].id);
|
||||
expect(night.stop.day_id).toBe(rows[1].id);
|
||||
|
||||
const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
|
||||
|
||||
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeUndefined();
|
||||
expect(reservationRow(night.reservationId)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(night.budgetId)).toBeUndefined();
|
||||
// The hotel itself is a place and stays on the list.
|
||||
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(night.place.id)).toBeDefined();
|
||||
expect(result).toMatchObject({ stayIds: [night.stayId], reservationIds: [night.reservationId], budgetItemIds: [night.budgetId] });
|
||||
// Its stop sat on the deleted day, which day:deleted already covers.
|
||||
expect(result.mirrors[0].removed).toEqual([]);
|
||||
});
|
||||
|
||||
it('DAY-DEL-006 a stay checking out on the day is cancelled too, and its stop on the check-in day goes with it', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
|
||||
const night = bookedNight(trip.id, rows[0].id, rows[1].id);
|
||||
|
||||
const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
|
||||
|
||||
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeUndefined();
|
||||
expect(reservationRow(night.reservationId)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(night.stop.id)).toBeUndefined();
|
||||
expect(result.mirrors[0].removed).toEqual([{ id: night.stop.id, dayId: rows[0].id }]);
|
||||
});
|
||||
|
||||
it('DAY-DEL-007 a stay that only runs across the day keeps standing', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
|
||||
const night = bookedNight(trip.id, rows[0].id, rows[2].id);
|
||||
|
||||
const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
|
||||
|
||||
expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(night.stayId))
|
||||
.toEqual({ start_day_id: rows[0].id, end_day_id: rows[2].id });
|
||||
expect(reservationRow(night.reservationId)).toBeDefined();
|
||||
expect(result.stayIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('DAY-DEL-008 bookings on the day are let go of, with their dates untouched', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
|
||||
const [d1, d2] = rows;
|
||||
const dinner = booking(trip.id, d2.id, '2026-01-02T19:00');
|
||||
const car = booking(trip.id, d1.id, '2026-01-01T09:00', { endDayId: d2.id, endTime: '2026-01-02T10:00' });
|
||||
|
||||
removal.remove(trip.id, d2.id, { userId: user.id });
|
||||
|
||||
expect(reservationRow(dinner)).toEqual({ day_id: null, end_day_id: null, reservation_time: '2026-01-02T19:00', reservation_end_time: null });
|
||||
expect(reservationRow(car)).toEqual({ day_id: d1.id, end_day_id: null, reservation_time: '2026-01-01T09:00', reservation_end_time: '2026-01-02T10:00' });
|
||||
});
|
||||
|
||||
it('DAY-DEL-009 the road trip boundaries after the day move up with their days', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-04');
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const stops = rows.map(r => createDayAssignment(testDb, r.id, place.id));
|
||||
rows.forEach((_, i) => boundary(trip.id, i + 1, stops[i].id));
|
||||
|
||||
const result = removal.remove(trip.id, rows[1].id, { userId: user.id });
|
||||
|
||||
const expected = [
|
||||
{ day_number: 1, from_assignment_id: stops[0].id, to_assignment_id: null, fraction: 1 },
|
||||
{ day_number: 2, from_assignment_id: stops[2].id, to_assignment_id: null, fraction: 1 },
|
||||
{ day_number: 3, from_assignment_id: stops[3].id, to_assignment_id: null, fraction: 1 },
|
||||
];
|
||||
expect(testDb.prepare('SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number').all(trip.id))
|
||||
.toEqual(expected);
|
||||
expect(result.boundaries).toEqual(expected);
|
||||
});
|
||||
|
||||
it('DAY-DEL-010 deleting the day an insert slotted in gives back the trip as it was', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
|
||||
const train = booking(trip.id, rows[2].id, '2026-01-03T08:00');
|
||||
const before = { rows: dayRows(trip.id), range: range(trip.id), train: reservationRow(train) };
|
||||
|
||||
const inserted = days.insert(trip.id, 2);
|
||||
expect(range(trip.id).end_date).toBe('2026-01-04');
|
||||
removal.remove(trip.id, inserted.id, { userId: user.id });
|
||||
|
||||
expect({ rows: dayRows(trip.id), range: range(trip.id), train: reservationRow(train) }).toEqual(before);
|
||||
});
|
||||
|
||||
it('DAY-DEL-011 appending a day, moving it into a slot and deleting the day it pushed out keeps every date', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
|
||||
const [d1, d2, d3] = rows;
|
||||
const lunch = booking(trip.id, d2.id, '2026-01-02T12:00');
|
||||
const tour = booking(trip.id, d3.id, '2026-01-03T09:00');
|
||||
|
||||
const appended = days.create(trip.id);
|
||||
days.reorder(trip.id, [d1.id, d2.id, appended.id, d3.id]);
|
||||
// The pushed-out day lost its date to the new one and sits at the end without one.
|
||||
expect(dayRows(trip.id).at(-1)).toEqual({ id: d3.id, day_number: 4, date: null });
|
||||
removal.remove(trip.id, d3.id, { userId: user.id });
|
||||
|
||||
expect(dayRows(trip.id)).toEqual([
|
||||
{ id: d1.id, day_number: 1, date: '2026-01-01' },
|
||||
{ id: d2.id, day_number: 2, date: '2026-01-02' },
|
||||
{ id: appended.id, day_number: 3, date: '2026-01-03' },
|
||||
]);
|
||||
expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' });
|
||||
expect(reservationRow(lunch)?.reservation_time).toBe('2026-01-02T12:00');
|
||||
expect(reservationRow(tour)?.reservation_time).toBe('2026-01-03T09:00');
|
||||
});
|
||||
|
||||
it('DAY-DEL-012 a failure halfway rolls the whole delete back', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
|
||||
const night = bookedNight(trip.id, rows[1].id, rows[2].id);
|
||||
vi.spyOn(days, 'restampReservationDates').mockImplementation(() => { throw new Error('disk full'); });
|
||||
|
||||
expect(() => removal.remove(trip.id, rows[1].id, { userId: user.id })).toThrow('disk full');
|
||||
|
||||
expect(dayRows(trip.id)).toEqual(rows);
|
||||
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeDefined();
|
||||
expect(reservationRow(night.reservationId)).toBeDefined();
|
||||
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(night.budgetId)).toBeDefined();
|
||||
});
|
||||
|
||||
it('DAY-DEL-013 the journey catches up after the commit, and a failure there does not undo the delete', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-02');
|
||||
const reconcile = vi.spyOn(journey, 'reconcileTripSkeletons').mockImplementation(() => { throw new Error('journey down'); });
|
||||
|
||||
const result = removal.remove(trip.id, rows[0].id, { userId: user.id, socketId: 'sock-1' });
|
||||
|
||||
expect(reconcile).toHaveBeenCalledWith(trip.id, 'sock-1');
|
||||
expect(result.orderedIds).toEqual([rows[1].id]);
|
||||
expect(dayRows(trip.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('DAY-DEL-015 a day of another trip is refused, and nothing is written', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-02');
|
||||
const other = datedTrip('2026-02-01', '2026-02-02');
|
||||
|
||||
expect(() => removal.remove(trip.id, other.rows[0].id, { userId: user.id })).toThrow(new DayDeleteError('Day not found'));
|
||||
expect(dayRows(trip.id)).toEqual(rows);
|
||||
expect(dayRows(other.trip.id)).toEqual(other.rows);
|
||||
});
|
||||
|
||||
it('DAY-DEL-016 dated days on a trip without a range move their dates but leave the trip alone', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = createDay(testDb, trip.id, { date: '2026-04-01' });
|
||||
const b = createDay(testDb, trip.id, { date: '2026-04-02' });
|
||||
|
||||
const result = removal.remove(trip.id, a.id, { userId: user.id });
|
||||
|
||||
expect(dayRows(trip.id)).toEqual([{ id: b.id, day_number: 1, date: '2026-04-01' }]);
|
||||
expect(range(trip.id)).toEqual({ start_date: null, end_date: null });
|
||||
expect(result.endDate).toBeNull();
|
||||
});
|
||||
|
||||
it('DAY-DEL-018 a cancelled stay does not announce the deleted day: neither its stop there nor its roads', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createDayAccommodation(testDb, trip.id, place.id, rows[1].id, rows[1].id);
|
||||
const stub = {
|
||||
deleteAccommodation: vi.fn(() => ({
|
||||
linkedReservationId: null, deletedBudgetItemId: null, linkedReservationIds: [], deletedBudgetItemIds: [],
|
||||
mirror: {
|
||||
created: null, moved: null, updated: [], stamped: null,
|
||||
removed: [{ id: 1, dayId: rows[1].id }, { id: 2, dayId: rows[0].id }],
|
||||
vias: [{ dayId: rows[1].id, vias: [] }, { dayId: rows[0].id, vias: [] }],
|
||||
},
|
||||
})),
|
||||
} as unknown as AccommodationsService;
|
||||
|
||||
const result = new DayRemovalService(dbs, days, stub, assignments).remove(trip.id, rows[1].id, { userId: user.id });
|
||||
|
||||
expect(result.mirrors).toEqual([{
|
||||
created: null, moved: null, updated: [], stamped: null,
|
||||
removed: [{ id: 2, dayId: rows[0].id }],
|
||||
vias: [{ dayId: rows[0].id, vias: [] }],
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DayRemovalService.announce', () => {
|
||||
function recorder() {
|
||||
const sent: [string, string, unknown][] = [];
|
||||
const all: MirrorSender = (event, payload) => { sent.push(['all', event, payload]); };
|
||||
const others: MirrorSender = (event, payload) => { sent.push(['others', event, payload]); };
|
||||
return { sent, all, others };
|
||||
}
|
||||
|
||||
it('DAY-DEL-014 fans out in one order: the day, the new order, the stays, their rows, the boundaries, the trip', () => {
|
||||
const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03');
|
||||
const night = bookedNight(trip.id, rows[0].id, rows[1].id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
boundary(trip.id, 3, createDayAssignment(testDb, rows[2].id, place.id).id);
|
||||
const result = removal.remove(trip.id, rows[1].id, { userId: user.id, socketId: 'sock' });
|
||||
const mirror = vi.spyOn(accommodations, 'announceMirror');
|
||||
const { sent, all, others } = recorder();
|
||||
|
||||
removal.announce(trip.id, result, { all, others, socketId: 'sock' });
|
||||
|
||||
expect(sent.map(([to, event]) => `${to} ${event}`)).toEqual([
|
||||
'others day:deleted',
|
||||
'others day:reordered',
|
||||
'all assignment:deleted',
|
||||
'all assignment:reordered',
|
||||
'all reservation:deleted',
|
||||
'all budget:deleted',
|
||||
'others accommodation:deleted',
|
||||
'all roadtripBoundary:changed',
|
||||
'others trip:updated',
|
||||
]);
|
||||
expect(sent[0][2]).toEqual({ dayId: rows[1].id });
|
||||
expect(sent[1][2]).toEqual({ orderedIds: [rows[0].id, rows[2].id] });
|
||||
expect(sent[2][2]).toEqual({ assignmentId: night.stop.id, dayId: rows[0].id });
|
||||
expect(sent[4][2]).toEqual({ reservationId: night.reservationId });
|
||||
expect(sent[5][2]).toEqual({ itemId: night.budgetId });
|
||||
expect(sent[6][2]).toEqual({ accommodationId: night.stayId });
|
||||
expect(sent[7][2]).toEqual({ boundaries: [expect.objectContaining({ day_number: 2 })] });
|
||||
expect(sent[8][2]).toEqual({ trip: expect.objectContaining({ id: trip.id, end_date: '2026-01-02' }) });
|
||||
expect(mirror).toHaveBeenCalledWith(trip.id, result.mirrors[0], all, 'sock');
|
||||
});
|
||||
|
||||
it('DAY-DEL-017 a plain delete announces the day and the new order, nothing else', () => {
|
||||
const removed: DayRemoval = {
|
||||
dayId: 4, orderedIds: [3, 5], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [],
|
||||
boundaries: null, endDate: null, trip: null,
|
||||
};
|
||||
const { sent, all, others } = recorder();
|
||||
|
||||
removal.announce(9, removed, { all, others });
|
||||
|
||||
expect(sent).toEqual([
|
||||
['others', 'day:deleted', { dayId: 4 }],
|
||||
['others', 'day:reordered', { orderedIds: [3, 5] }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { DaysController } from '../../../src/nest/days/days.controller';
|
||||
import { DayNotesController } from '../../../src/nest/day-notes/day-notes.controller';
|
||||
import { DayNoteCreateDto, DayNoteUpdateDto } from '../../../src/nest/day-notes/day-notes.dto';
|
||||
import { DayReorderError } from '../../../src/nest/days/days.service';
|
||||
import { DayDeleteError, type DayRemoval, type DayRemovalService } from '../../../src/nest/days/day-removal.service';
|
||||
import type { DayReorderDto } from '../../../src/nest/days/days.dto';
|
||||
import type { DaysService } from '../../../src/nest/days/days.service';
|
||||
import type { DayNotesService } from '../../../src/nest/day-notes/day-notes.service';
|
||||
@@ -24,6 +25,9 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
function daysSvc(o: Partial<DaysService> = {}): DaysService {
|
||||
return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), ...o } as unknown as DaysService;
|
||||
}
|
||||
function removalSvc(o: Partial<DayRemovalService> = {}): DayRemovalService {
|
||||
return { remove: vi.fn(), announce: vi.fn(), ...o } as unknown as DayRemovalService;
|
||||
}
|
||||
function notesSvc(o: Partial<DayNotesService> = {}): DayNotesService {
|
||||
return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), ...o } as unknown as DayNotesService;
|
||||
}
|
||||
@@ -44,12 +48,12 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
|
||||
|
||||
it('GET / returns the list service result verbatim (the { days } envelope)', () => {
|
||||
const svc = daysSvc({ list: vi.fn().mockReturnValue({ days: [{ id: 1 }] }) } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).list(user, '5')).toEqual({ days: [{ id: 1 }] });
|
||||
expect(new DaysController(svc, removalSvc()).list(user, '5')).toEqual({ days: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST / creates + broadcasts', () => {
|
||||
const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn();
|
||||
expect(new DaysController(daysSvc({ create, broadcast } as Partial<DaysService>)).create(user, '5', { date: '2026-07-01' }, 'sock')).toEqual({ day: { id: 9 } });
|
||||
expect(new DaysController(daysSvc({ create, broadcast } as Partial<DaysService>), removalSvc()).create(user, '5', { date: '2026-07-01' }, 'sock')).toEqual({ day: { id: 9 } });
|
||||
expect(create).toHaveBeenCalledWith('5', '2026-07-01', undefined);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'day:created', { day: { id: 9 } }, 'sock');
|
||||
});
|
||||
@@ -58,7 +62,7 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
|
||||
it('POST / with a position inserts + broadcasts day:reordered', () => {
|
||||
const insert = vi.fn().mockReturnValue({ id: 12 }); const create = vi.fn(); const broadcast = vi.fn();
|
||||
const svc = daysSvc({ insert, create, broadcast } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } });
|
||||
expect(new DaysController(svc, removalSvc()).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } });
|
||||
expect(insert).toHaveBeenCalledWith('5', 0);
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { day: { id: 12 } }, 'sock');
|
||||
@@ -68,17 +72,17 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
|
||||
|
||||
|
||||
it('400 when orderedIds is missing', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', rawReorderBody({})))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
|
||||
expect(thrown(() => new DaysController(daysSvc(), removalSvc()).reorder(user, '5', rawReorderBody({})))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
|
||||
});
|
||||
|
||||
it('400 when orderedIds is not an array', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', rawReorderBody({ orderedIds: 'nope' })))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
|
||||
expect(thrown(() => new DaysController(daysSvc(), removalSvc()).reorder(user, '5', rawReorderBody({ orderedIds: 'nope' })))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
|
||||
});
|
||||
|
||||
it('maps a DayReorderError to 400 with its message', () => {
|
||||
const reorder = vi.fn(() => { throw new DayReorderError('orderedIds must be a permutation of the trip day ids.'); });
|
||||
const svc = daysSvc({ reorder } as Partial<DaysService>);
|
||||
expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [9] }))).toEqual({
|
||||
expect(thrown(() => new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [9] }))).toEqual({
|
||||
status: 400, body: { error: 'orderedIds must be a permutation of the trip day ids.' },
|
||||
});
|
||||
});
|
||||
@@ -87,29 +91,71 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
|
||||
const boom = new Error('db is down');
|
||||
const reorder = vi.fn(() => { throw boom; });
|
||||
const svc = daysSvc({ reorder } as Partial<DaysService>);
|
||||
expect(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] })).toThrow(boom);
|
||||
expect(() => new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [1, 2] })).toThrow(boom);
|
||||
});
|
||||
|
||||
it('reorders and broadcasts day:reordered', () => {
|
||||
const reorder = vi.fn(); const broadcast = vi.fn();
|
||||
const svc = daysSvc({ reorder, broadcast } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true });
|
||||
expect(new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true });
|
||||
expect(reorder).toHaveBeenCalledWith('5', [2, 1]);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { orderedIds: [2, 1] }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /:id 404 when the day is missing, else updates', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>)).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>), removalSvc()).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
const update = vi.fn().mockReturnValue({ id: 9, title: 'T' });
|
||||
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), update } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).update(user, '5', '9', { title: 'T' })).toEqual({ day: { id: 9, title: 'T' } });
|
||||
expect(new DaysController(svc, removalSvc()).update(user, '5', '9', { title: 'T' })).toEqual({ day: { id: 9, title: 'T' } });
|
||||
});
|
||||
|
||||
it('DELETE /:id 404 when missing, else success', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), remove: vi.fn() } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).remove(user, '5', '9')).toEqual({ success: true });
|
||||
describe('DELETE /:id', () => {
|
||||
const removed: DayRemoval = {
|
||||
dayId: 9, orderedIds: [8, 10], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [],
|
||||
boundaries: null, endDate: '2026-07-02', trip: { id: 5, end_date: '2026-07-02' },
|
||||
};
|
||||
|
||||
it('404 when the day is not on the trip, before anything is removed', () => {
|
||||
const removal = removalSvc();
|
||||
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>), removal).remove(user, '5', '9')))
|
||||
.toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
expect(removal.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 with the service sentence for the last day, and nothing is announced', () => {
|
||||
const removal = removalSvc({ remove: vi.fn(() => { throw new DayDeleteError('A trip needs at least one day.'); }) });
|
||||
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }) } as Partial<DaysService>);
|
||||
expect(thrown(() => new DaysController(svc, removal).remove(user, '5', '9', 'sock')))
|
||||
.toEqual({ status: 400, body: { error: 'A trip needs at least one day.' } });
|
||||
expect(removal.announce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows anything else unchanged', () => {
|
||||
const boom = new Error('db is down');
|
||||
const removal = removalSvc({ remove: vi.fn(() => { throw boom; }) });
|
||||
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }) } as Partial<DaysService>);
|
||||
expect(() => new DaysController(svc, removal).remove(user, '5', '9')).toThrow(boom);
|
||||
});
|
||||
|
||||
it('removes as the caller, announces with and without the socket, and answers with the trip', () => {
|
||||
const broadcast = vi.fn();
|
||||
const removal = removalSvc({ remove: vi.fn().mockReturnValue(removed) });
|
||||
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), broadcast } as Partial<DaysService>);
|
||||
|
||||
expect(new DaysController(svc, removal).remove(user, '5', '9', 'sock'))
|
||||
.toEqual({ success: true, trip: { id: 5, end_date: '2026-07-02' } });
|
||||
expect(removal.remove).toHaveBeenCalledWith('5', '9', { userId: 1, socketId: 'sock' });
|
||||
|
||||
const [tripId, sentRemoval, senders] = vi.mocked(removal.announce).mock.calls[0];
|
||||
expect([tripId, sentRemoval, senders.socketId]).toEqual(['5', removed, 'sock']);
|
||||
senders.all('budget:deleted', { itemId: 3 });
|
||||
senders.others('day:deleted', { dayId: 9 });
|
||||
expect(broadcast.mock.calls).toEqual([
|
||||
['5', 'budget:deleted', { itemId: 3 }, undefined],
|
||||
['5', 'day:deleted', { dayId: 9 }, 'sock'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -247,13 +247,22 @@ describe('update', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('DAY-SVC-015 — deletes the day', () => {
|
||||
// DAY-SVC-015 pinned the bare delete this class used to own. Deleting a day moved
|
||||
// to DayRemovalService with the renumbering and the stay cancellation around it;
|
||||
// its cases are DAY-DEL-001 to DAY-DEL-018 in day-removal.service.test.ts.
|
||||
|
||||
describe('getTripForViewer', () => {
|
||||
it('DAY-SVC-098: reads the trip in list shape, ownership from the viewer, without the feed token', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
svc.remove(day.id);
|
||||
expect(svc.getDay(day.id, trip.id)).toBeUndefined();
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-06-01', end_date: '2026-06-02' });
|
||||
testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run('secret-feed', trip.id);
|
||||
|
||||
expect(svc.getTripForViewer(trip.id, user.id)).toMatchObject({
|
||||
id: trip.id, end_date: '2026-06-02', day_count: 2, place_count: 0, is_owner: 1, feed_token: null,
|
||||
});
|
||||
expect(svc.getTripForViewer(trip.id, member.id)).toMatchObject({ id: trip.id, is_owner: 0 });
|
||||
expect(svc.getTripForViewer(99999, user.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ describe('every schema-validated method rejects a payload its schema refuses', (
|
||||
const anything = new Proxy({}, { get: () => vi.fn(() => ({ id: 1, trip: { id: 1 }, updatedTrip: { id: 1 }, reservation: { id: 1 } })) }) as never;
|
||||
const registry = createTestPluginRegistry([
|
||||
new PlacesRpc(anything, anything, realtime(), guards),
|
||||
new DaysRpc(anything, realtime(), guards),
|
||||
new DaysRpc(anything, realtime(), guards, anything),
|
||||
new PackingRpc(anything, realtime(), guards),
|
||||
new TripsRpc(anything, anything, anything, anything, db, realtime(), guards, anything, anything),
|
||||
new AccommodationsRpc(anything, realtime(), guards),
|
||||
|
||||
@@ -88,5 +88,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'إعادة ترتيب الأيام',
|
||||
'dayplan.reorderError': 'تعذّر إعادة ترتيب الأيام',
|
||||
'dayplan.addDayError': 'تعذّر إضافة يوم',
|
||||
'dayplan.deleteDay': 'حذف اليوم',
|
||||
'dayplan.deleteDayTitle': 'حذف {day}؟',
|
||||
'dayplan.deleteDayBody': 'سيُزال اليوم من الرحلة. لا يمكن التراجع عن ذلك.',
|
||||
'dayplan.deleteDayEmpty': 'لا يوجد شيء مخطط لهذا اليوم.',
|
||||
'dayplan.impactPlaces': 'الأماكن المخططة: {count}',
|
||||
'dayplan.impactPlacesHint': 'تبقى في قائمة الأماكن.',
|
||||
'dayplan.impactNotes': 'الملاحظات: {count}',
|
||||
'dayplan.impactTexts': 'عناوين الأيام وأوصافها: {count}',
|
||||
'dayplan.impactDeletedHint': 'تُحذف أيضًا.',
|
||||
'dayplan.impactBookings': 'الحجوزات: {count}',
|
||||
'dayplan.impactStay': 'الإقامة في {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'تبقى ضمن الحجوزات، دون يوم.',
|
||||
'dayplan.deleteDayStayHint': 'يقع تسجيل الوصول أو المغادرة في هذا اليوم، لذا تُلغى الإقامة.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'يقع تسجيل الوصول أو المغادرة في هذا اليوم، لذا تُلغى الإقامة مع الحجز "{booking}" ومصروفه.',
|
||||
'dayplan.deleteDayShift': 'الأيام اللاحقة: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'ينتقل كل منها إلى التاريخ السابق.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'ينتقل كل منها إلى التاريخ السابق. الحجوزات التي تنتقل معها: {count}',
|
||||
'dayplan.deleteDayShrink': 'تنتهي الرحلة الآن في {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'لا يوجد يوم بلا تاريخ ليأخذ التاريخ الأخير.',
|
||||
'dayplan.deleteDayLast': 'تحتاج الرحلة إلى يوم واحد على الأقل',
|
||||
'dayplan.daysOffline': 'تغيير الأيام يتطلب اتصالًا',
|
||||
'dayplan.deleteDaySuccess': 'تم حذف اليوم',
|
||||
'dayplan.deleteDayError': 'تعذّر حذف اليوم',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Reordenar dias',
|
||||
'dayplan.reorderError': 'Falha ao reordenar os dias',
|
||||
'dayplan.addDayError': 'Falha ao adicionar o dia',
|
||||
'dayplan.deleteDay': 'Excluir dia',
|
||||
'dayplan.deleteDayTitle': 'Excluir {day}?',
|
||||
'dayplan.deleteDayBody': 'O dia é removido da viagem. Isso não pode ser desfeito.',
|
||||
'dayplan.deleteDayEmpty': 'Nada está planejado para este dia.',
|
||||
'dayplan.impactPlaces': 'Lugares planejados: {count}',
|
||||
'dayplan.impactPlacesHint': 'Eles continuam na lista de lugares.',
|
||||
'dayplan.impactNotes': 'Notas: {count}',
|
||||
'dayplan.impactTexts': 'Títulos e descrições do dia: {count}',
|
||||
'dayplan.impactDeletedHint': 'Também são excluídos.',
|
||||
'dayplan.impactBookings': 'Reservas: {count}',
|
||||
'dayplan.impactStay': 'Hospedagem em {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Elas continuam em Reservas, sem dia.',
|
||||
'dayplan.deleteDayStayHint': 'O check-in ou o check-out é neste dia, então a hospedagem é cancelada.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'O check-in ou o check-out é neste dia, então a hospedagem é cancelada, junto com a reserva “{booking}” e a despesa dela.',
|
||||
'dayplan.deleteDayShift': 'Dias seguintes: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Cada um passa para a data anterior.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'Cada um passa para a data anterior. Reservas que vão junto: {count}',
|
||||
'dayplan.deleteDayShrink': 'A viagem agora termina em {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Não há nenhum dia sem data para assumir a última data.',
|
||||
'dayplan.deleteDayLast': 'Uma viagem precisa de pelo menos um dia',
|
||||
'dayplan.daysOffline': 'Alterar os dias exige conexão',
|
||||
'dayplan.deleteDaySuccess': 'Dia excluído',
|
||||
'dayplan.deleteDayError': 'Falha ao excluir o dia',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -62,7 +62,7 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.gpxPlaces': 'Només els llocs',
|
||||
'dayplan.gpxDays': 'Dies com a rutes',
|
||||
'dayplan.gpxEmpty': 'Encara no hi ha res per exportar',
|
||||
'dayplan.gpxFailed': 'L\'exportació GPX ha fallat',
|
||||
'dayplan.gpxFailed': "L'exportació GPX ha fallat",
|
||||
'dayplan.pdfError': "No s'ha pogut exportar el PDF",
|
||||
'dayplan.cannotReorderTransport': 'Les reserves amb hora fixa no es poden reordenar',
|
||||
'dayplan.confirmRemoveTimeTitle': "Vols eliminar l'hora?",
|
||||
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Reordena els dies',
|
||||
'dayplan.reorderError': "No s'han pogut reordenar els dies",
|
||||
'dayplan.addDayError': "No s'ha pogut afegir el dia",
|
||||
'dayplan.deleteDay': 'Elimina el dia',
|
||||
'dayplan.deleteDayTitle': 'Vols eliminar {day}?',
|
||||
'dayplan.deleteDayBody': "El dia s'elimina del viatge. No es pot desfer.",
|
||||
'dayplan.deleteDayEmpty': 'No hi ha res planificat aquest dia.',
|
||||
'dayplan.impactPlaces': 'Llocs planificats: {count}',
|
||||
'dayplan.impactPlacesHint': 'Es queden a la llista de llocs.',
|
||||
'dayplan.impactNotes': 'Notes: {count}',
|
||||
'dayplan.impactTexts': 'Títols i descripcions del dia: {count}',
|
||||
'dayplan.impactDeletedHint': "També s'eliminen.",
|
||||
'dayplan.impactBookings': 'Reserves: {count}',
|
||||
'dayplan.impactStay': 'Estada a {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Es queden a Reserves, sense dia.',
|
||||
'dayplan.deleteDayStayHint': "L'entrada o la sortida és aquest dia, així que l'estada es cancel·la.",
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
"L'entrada o la sortida és aquest dia, així que l'estada es cancel·la, juntament amb la reserva «{booking}» i la seva despesa.",
|
||||
'dayplan.deleteDayShift': 'Dies posteriors: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Cadascun passa a la data anterior.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'Cadascun passa a la data anterior. Reserves que es mouen amb ells: {count}',
|
||||
'dayplan.deleteDayShrink': 'Ara el viatge acaba el {date}',
|
||||
'dayplan.deleteDayShrinkHint': "No queda cap dia sense data que pugui agafar l'última data.",
|
||||
'dayplan.deleteDayLast': 'Un viatge necessita com a mínim un dia',
|
||||
'dayplan.daysOffline': 'Per canviar els dies cal connexió',
|
||||
'dayplan.deleteDaySuccess': 'Dia eliminat',
|
||||
'dayplan.deleteDayError': "No s'ha pogut eliminar el dia",
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Změnit pořadí dnů',
|
||||
'dayplan.reorderError': 'Nepodařilo se změnit pořadí dnů',
|
||||
'dayplan.addDayError': 'Nepodařilo se přidat den',
|
||||
'dayplan.deleteDay': 'Smazat den',
|
||||
'dayplan.deleteDayTitle': 'Smazat {day}?',
|
||||
'dayplan.deleteDayBody': 'Den bude z cesty odebrán. Tuto akci nelze vrátit zpět.',
|
||||
'dayplan.deleteDayEmpty': 'Na tento den není nic naplánováno.',
|
||||
'dayplan.impactPlaces': 'Naplánovaná místa: {count}',
|
||||
'dayplan.impactPlacesHint': 'Zůstanou v seznamu míst.',
|
||||
'dayplan.impactNotes': 'Poznámky: {count}',
|
||||
'dayplan.impactTexts': 'Názvy a popisy dne: {count}',
|
||||
'dayplan.impactDeletedHint': 'Budou také smazány.',
|
||||
'dayplan.impactBookings': 'Rezervace: {count}',
|
||||
'dayplan.impactStay': 'Ubytování v {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Zůstanou v Rezervacích, bez dne.',
|
||||
'dayplan.deleteDayStayHint': 'Příjezd nebo odjezd připadá na tento den, proto se ubytování zruší.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Příjezd nebo odjezd připadá na tento den, proto se ubytování zruší spolu s rezervací „{booking}“ a jejím výdajem.',
|
||||
'dayplan.deleteDayShift': 'Pozdější dny: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Každý se posune o jedno datum dříve.',
|
||||
'dayplan.deleteDayShiftBookingsHint':
|
||||
'Každý se posune o jedno datum dříve. Rezervace, které se posunou s nimi: {count}',
|
||||
'dayplan.deleteDayShrink': 'Cesta nyní končí {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Nezbývá žádný den bez data, který by převzal poslední datum.',
|
||||
'dayplan.deleteDayLast': 'Cesta musí mít alespoň jeden den',
|
||||
'dayplan.daysOffline': 'Změna dnů vyžaduje připojení',
|
||||
'dayplan.deleteDaySuccess': 'Den smazán',
|
||||
'dayplan.deleteDayError': 'Den se nepodařilo smazat',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Tage neu anordnen',
|
||||
'dayplan.reorderError': 'Tage konnten nicht neu angeordnet werden',
|
||||
'dayplan.addDayError': 'Tag konnte nicht hinzugefügt werden',
|
||||
'dayplan.deleteDay': 'Tag löschen',
|
||||
'dayplan.deleteDayTitle': '{day} löschen?',
|
||||
'dayplan.deleteDayBody': 'Der Tag wird aus der Reise entfernt. Das lässt sich nicht rückgängig machen.',
|
||||
'dayplan.deleteDayEmpty': 'An diesem Tag ist nichts geplant.',
|
||||
'dayplan.impactPlaces': 'Geplante Orte: {count}',
|
||||
'dayplan.impactPlacesHint': 'Sie bleiben in der Ortsliste.',
|
||||
'dayplan.impactNotes': 'Notizen: {count}',
|
||||
'dayplan.impactTexts': 'Tagestitel und Beschreibungen: {count}',
|
||||
'dayplan.impactDeletedHint': 'Werden mitgelöscht.',
|
||||
'dayplan.impactBookings': 'Buchungen: {count}',
|
||||
'dayplan.impactStay': 'Übernachtung in {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Sie bleiben unter Buchungen, ohne Tag.',
|
||||
'dayplan.deleteDayStayHint': 'Check-in oder Check-out an diesem Tag, sie wird storniert.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Check-in oder Check-out an diesem Tag, sie wird storniert, zusammen mit der Buchung „{booking}“ und ihrer Ausgabe.',
|
||||
'dayplan.deleteDayShift': 'Spätere Tage: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Jeder rückt um einen Kalendertag nach vorn.',
|
||||
'dayplan.deleteDayShiftBookingsHint':
|
||||
'Jeder rückt um einen Kalendertag nach vorn. Buchungen, die mitwandern: {count}',
|
||||
'dayplan.deleteDayShrink': 'Die Reise endet jetzt am {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Es gibt keinen Tag ohne Datum, der das letzte Datum übernehmen könnte.',
|
||||
'dayplan.deleteDayLast': 'Eine Reise braucht mindestens einen Tag',
|
||||
'dayplan.daysOffline': 'Tage ändern geht nur mit Verbindung',
|
||||
'dayplan.deleteDaySuccess': 'Tag gelöscht',
|
||||
'dayplan.deleteDayError': 'Tag konnte nicht gelöscht werden',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Reorder days',
|
||||
'dayplan.reorderError': 'Failed to reorder days',
|
||||
'dayplan.addDayError': 'Failed to add day',
|
||||
'dayplan.deleteDay': 'Delete day',
|
||||
'dayplan.deleteDayTitle': 'Delete {day}?',
|
||||
'dayplan.deleteDayBody': 'The day is removed from the trip. This cannot be undone.',
|
||||
'dayplan.deleteDayEmpty': 'Nothing is planned on this day.',
|
||||
'dayplan.impactPlaces': 'Planned places: {count}',
|
||||
'dayplan.impactPlacesHint': 'They stay in the place list.',
|
||||
'dayplan.impactNotes': 'Notes: {count}',
|
||||
'dayplan.impactTexts': 'Day titles and descriptions: {count}',
|
||||
'dayplan.impactDeletedHint': 'Deleted as well.',
|
||||
'dayplan.impactBookings': 'Bookings: {count}',
|
||||
'dayplan.impactStay': 'Stay at {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'They stay under Bookings, without a day.',
|
||||
'dayplan.deleteDayStayHint': 'Checks in or out on this day and is cancelled.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Checks in or out on this day and is cancelled, together with the booking “{booking}” and its expense.',
|
||||
'dayplan.deleteDayShift': 'Later days: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Each moves one date earlier.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'Each moves one date earlier. Bookings moving along: {count}',
|
||||
'dayplan.deleteDayShrink': 'Trip now ends on {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'There is no day without a date to take over the last date.',
|
||||
'dayplan.deleteDayLast': 'A trip needs at least one day',
|
||||
'dayplan.daysOffline': 'Changing days needs a connection',
|
||||
'dayplan.deleteDaySuccess': 'Day deleted',
|
||||
'dayplan.deleteDayError': 'Failed to delete day',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Reordenar días',
|
||||
'dayplan.reorderError': 'No se pudieron reordenar los días',
|
||||
'dayplan.addDayError': 'No se pudo añadir el día',
|
||||
'dayplan.deleteDay': 'Eliminar día',
|
||||
'dayplan.deleteDayTitle': '¿Eliminar {day}?',
|
||||
'dayplan.deleteDayBody': 'El día se quita del viaje. No se puede deshacer.',
|
||||
'dayplan.deleteDayEmpty': 'No hay nada planificado este día.',
|
||||
'dayplan.impactPlaces': 'Lugares planificados: {count}',
|
||||
'dayplan.impactPlacesHint': 'Se quedan en la lista de lugares.',
|
||||
'dayplan.impactNotes': 'Notas: {count}',
|
||||
'dayplan.impactTexts': 'Títulos y descripciones del día: {count}',
|
||||
'dayplan.impactDeletedHint': 'También se eliminan.',
|
||||
'dayplan.impactBookings': 'Reservas: {count}',
|
||||
'dayplan.impactStay': 'Estancia en {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Se quedan en Reservas, sin día.',
|
||||
'dayplan.deleteDayStayHint': 'La entrada o la salida es este día, así que la estancia se cancela.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'La entrada o la salida es este día, así que la estancia se cancela, junto con la reserva «{booking}» y su gasto.',
|
||||
'dayplan.deleteDayShift': 'Días posteriores: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Cada uno pasa a la fecha anterior.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'Cada uno pasa a la fecha anterior. Reservas que se mueven con ellos: {count}',
|
||||
'dayplan.deleteDayShrink': 'El viaje termina ahora el {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'No queda ningún día sin fecha que tome la última fecha.',
|
||||
'dayplan.deleteDayLast': 'Un viaje necesita al menos un día',
|
||||
'dayplan.daysOffline': 'Cambiar los días requiere conexión',
|
||||
'dayplan.deleteDaySuccess': 'Día eliminado',
|
||||
'dayplan.deleteDayError': 'No se pudo eliminar el día',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -61,8 +61,8 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.gpxAll': 'Tout le voyage',
|
||||
'dayplan.gpxPlaces': 'Lieux uniquement',
|
||||
'dayplan.gpxDays': 'Journées en itinéraires',
|
||||
'dayplan.gpxEmpty': 'Il n\'y a encore rien à exporter',
|
||||
'dayplan.gpxFailed': 'Échec de l\'export GPX',
|
||||
'dayplan.gpxEmpty': "Il n'y a encore rien à exporter",
|
||||
'dayplan.gpxFailed': "Échec de l'export GPX",
|
||||
'dayplan.pdfError': "Échec de l'export PDF",
|
||||
'dayplan.cannotReorderTransport': 'Les réservations avec une heure fixe ne peuvent pas être réorganisées',
|
||||
'dayplan.confirmRemoveTimeTitle': "Supprimer l'heure ?",
|
||||
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Réorganiser les jours',
|
||||
'dayplan.reorderError': 'Échec de la réorganisation des jours',
|
||||
'dayplan.addDayError': "Échec de l'ajout du jour",
|
||||
'dayplan.deleteDay': 'Supprimer le jour',
|
||||
'dayplan.deleteDayTitle': 'Supprimer {day} ?',
|
||||
'dayplan.deleteDayBody': 'Le jour est retiré du voyage. Cette action est irréversible.',
|
||||
'dayplan.deleteDayEmpty': "Rien n'est prévu ce jour-là.",
|
||||
'dayplan.impactPlaces': 'Lieux prévus : {count}',
|
||||
'dayplan.impactPlacesHint': 'Ils restent dans la liste des lieux.',
|
||||
'dayplan.impactNotes': 'Notes : {count}',
|
||||
'dayplan.impactTexts': 'Titres et descriptions du jour : {count}',
|
||||
'dayplan.impactDeletedHint': 'Supprimés également.',
|
||||
'dayplan.impactBookings': 'Réservations : {count}',
|
||||
'dayplan.impactStay': 'Séjour à {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Elles restent dans Réservations, sans jour.',
|
||||
'dayplan.deleteDayStayHint': "L'arrivée ou le départ a lieu ce jour-là, le séjour est donc annulé.",
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
"L'arrivée ou le départ a lieu ce jour-là, le séjour est donc annulé, ainsi que la réservation « {booking} » et sa dépense.",
|
||||
'dayplan.deleteDayShift': 'Jours suivants : {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Chacun passe à la date précédente.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'Chacun passe à la date précédente. Réservations déplacées avec eux : {count}',
|
||||
'dayplan.deleteDayShrink': 'Le voyage se termine désormais le {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Aucun jour sans date ne peut reprendre la dernière date.',
|
||||
'dayplan.deleteDayLast': 'Un voyage doit compter au moins un jour',
|
||||
'dayplan.daysOffline': 'Modifier les jours nécessite une connexion',
|
||||
'dayplan.deleteDaySuccess': 'Jour supprimé',
|
||||
'dayplan.deleteDayError': 'Échec de la suppression du jour',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Αναδιάταξη ημερών',
|
||||
'dayplan.reorderError': 'Η αναδιάταξη των ημερών απέτυχε',
|
||||
'dayplan.addDayError': 'Η προσθήκη ημέρας απέτυχε',
|
||||
'dayplan.deleteDay': 'Διαγραφή ημέρας',
|
||||
'dayplan.deleteDayTitle': 'Διαγραφή {day};',
|
||||
'dayplan.deleteDayBody': 'Η ημέρα αφαιρείται από το ταξίδι. Αυτό δεν μπορεί να αναιρεθεί.',
|
||||
'dayplan.deleteDayEmpty': 'Δεν υπάρχει τίποτα προγραμματισμένο αυτή την ημέρα.',
|
||||
'dayplan.impactPlaces': 'Προγραμματισμένα μέρη: {count}',
|
||||
'dayplan.impactPlacesHint': 'Παραμένουν στη λίστα μερών.',
|
||||
'dayplan.impactNotes': 'Σημειώσεις: {count}',
|
||||
'dayplan.impactTexts': 'Τίτλοι και περιγραφές ημέρας: {count}',
|
||||
'dayplan.impactDeletedHint': 'Διαγράφονται επίσης.',
|
||||
'dayplan.impactBookings': 'Κρατήσεις: {count}',
|
||||
'dayplan.impactStay': 'Διαμονή στο {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Παραμένουν στις Κρατήσεις, χωρίς ημέρα.',
|
||||
'dayplan.deleteDayStayHint': 'Η άφιξη ή η αναχώρηση είναι αυτή την ημέρα, οπότε η διαμονή ακυρώνεται.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Η άφιξη ή η αναχώρηση είναι αυτή την ημέρα, οπότε η διαμονή ακυρώνεται μαζί με την κράτηση «{booking}» και το έξοδό της.',
|
||||
'dayplan.deleteDayShift': 'Επόμενες ημέρες: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Καθεμία μετακινείται μία ημερομηνία νωρίτερα.',
|
||||
'dayplan.deleteDayShiftBookingsHint':
|
||||
'Καθεμία μετακινείται μία ημερομηνία νωρίτερα. Κρατήσεις που μετακινούνται μαζί: {count}',
|
||||
'dayplan.deleteDayShrink': 'Το ταξίδι τελειώνει τώρα στις {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Δεν υπάρχει ημέρα χωρίς ημερομηνία για να πάρει την τελευταία ημερομηνία.',
|
||||
'dayplan.deleteDayLast': 'Ένα ταξίδι χρειάζεται τουλάχιστον μία ημέρα',
|
||||
'dayplan.daysOffline': 'Η αλλαγή ημερών απαιτεί σύνδεση',
|
||||
'dayplan.deleteDaySuccess': 'Η ημέρα διαγράφηκε',
|
||||
'dayplan.deleteDayError': 'Η διαγραφή της ημέρας απέτυχε',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Napok átrendezése',
|
||||
'dayplan.reorderError': 'Nem sikerült átrendezni a napokat',
|
||||
'dayplan.addDayError': 'Nem sikerült napot hozzáadni',
|
||||
'dayplan.deleteDay': 'Nap törlése',
|
||||
'dayplan.deleteDayTitle': '{day} törlése?',
|
||||
'dayplan.deleteDayBody': 'A nap kikerül az utazásból. Ez nem vonható vissza.',
|
||||
'dayplan.deleteDayEmpty': 'Erre a napra nincs semmi tervezve.',
|
||||
'dayplan.impactPlaces': 'Tervezett helyek: {count}',
|
||||
'dayplan.impactPlacesHint': 'A helyek listájában maradnak.',
|
||||
'dayplan.impactNotes': 'Jegyzetek: {count}',
|
||||
'dayplan.impactTexts': 'Napcímek és leírások: {count}',
|
||||
'dayplan.impactDeletedHint': 'Ezek is törlődnek.',
|
||||
'dayplan.impactBookings': 'Foglalások: {count}',
|
||||
'dayplan.impactStay': 'Szállás: {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'A Foglalások között maradnak, nap nélkül.',
|
||||
'dayplan.deleteDayStayHint':
|
||||
'A bejelentkezés vagy a kijelentkezés erre a napra esik, ezért a szállás lemondásra kerül.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'A bejelentkezés vagy a kijelentkezés erre a napra esik, ezért a szállás lemondásra kerül a(z) „{booking}” foglalással és annak kiadásával együtt.',
|
||||
'dayplan.deleteDayShift': 'Későbbi napok: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Mindegyik egy dátummal előrébb kerül.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'Mindegyik egy dátummal előrébb kerül. Velük együtt mozgó foglalások: {count}',
|
||||
'dayplan.deleteDayShrink': 'Az utazás mostantól ekkor ér véget: {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Nincs dátum nélküli nap, amely átvehetné az utolsó dátumot.',
|
||||
'dayplan.deleteDayLast': 'Egy utazáshoz legalább egy nap kell',
|
||||
'dayplan.daysOffline': 'A napok módosításához kapcsolat szükséges',
|
||||
'dayplan.deleteDaySuccess': 'Nap törölve',
|
||||
'dayplan.deleteDayError': 'Nem sikerült törölni a napot',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Atur ulang hari',
|
||||
'dayplan.reorderError': 'Gagal mengatur ulang hari',
|
||||
'dayplan.addDayError': 'Gagal menambah hari',
|
||||
'dayplan.deleteDay': 'Hapus hari',
|
||||
'dayplan.deleteDayTitle': 'Hapus {day}?',
|
||||
'dayplan.deleteDayBody': 'Hari tersebut dihapus dari perjalanan. Tindakan ini tidak dapat dibatalkan.',
|
||||
'dayplan.deleteDayEmpty': 'Tidak ada rencana pada hari tersebut.',
|
||||
'dayplan.impactPlaces': 'Tempat yang direncanakan: {count}',
|
||||
'dayplan.impactPlacesHint': 'Tempat tetap ada di daftar tempat.',
|
||||
'dayplan.impactNotes': 'Catatan: {count}',
|
||||
'dayplan.impactTexts': 'Judul dan deskripsi hari: {count}',
|
||||
'dayplan.impactDeletedHint': 'Ikut dihapus.',
|
||||
'dayplan.impactBookings': 'Pemesanan: {count}',
|
||||
'dayplan.impactStay': 'Menginap di {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Tetap ada di Pemesanan, tanpa hari.',
|
||||
'dayplan.deleteDayStayHint': 'Check-in atau check-out jatuh pada hari tersebut, jadi penginapan dibatalkan.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Check-in atau check-out jatuh pada hari tersebut, jadi penginapan dibatalkan bersama pemesanan “{booking}” dan pengeluarannya.',
|
||||
'dayplan.deleteDayShift': 'Hari berikutnya: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Masing-masing maju satu tanggal.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'Masing-masing maju satu tanggal. Pemesanan yang ikut berpindah: {count}',
|
||||
'dayplan.deleteDayShrink': 'Perjalanan kini berakhir pada {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Tidak ada hari tanpa tanggal yang dapat mengambil tanggal terakhir.',
|
||||
'dayplan.deleteDayLast': 'Perjalanan memerlukan setidaknya satu hari',
|
||||
'dayplan.daysOffline': 'Mengubah hari memerlukan koneksi',
|
||||
'dayplan.deleteDaySuccess': 'Hari dihapus',
|
||||
'dayplan.deleteDayError': 'Gagal menghapus hari',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -61,7 +61,7 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.gpxAll': 'Tutto il viaggio',
|
||||
'dayplan.gpxPlaces': 'Solo i luoghi',
|
||||
'dayplan.gpxDays': 'Giorni come itinerari',
|
||||
'dayplan.gpxEmpty': 'Non c\'è ancora nulla da esportare',
|
||||
'dayplan.gpxEmpty': "Non c'è ancora nulla da esportare",
|
||||
'dayplan.gpxFailed': 'Esportazione GPX non riuscita',
|
||||
'dayplan.pdfError': 'Impossibile esportare il PDF',
|
||||
'dayplan.cannotReorderTransport': 'Le prenotazioni con un orario fisso non possono essere riordinate',
|
||||
@@ -90,5 +90,30 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Riordina i giorni',
|
||||
'dayplan.reorderError': 'Riordino dei giorni non riuscito',
|
||||
'dayplan.addDayError': 'Aggiunta del giorno non riuscita',
|
||||
'dayplan.deleteDay': 'Elimina giorno',
|
||||
'dayplan.deleteDayTitle': 'Eliminare {day}?',
|
||||
'dayplan.deleteDayBody': 'Il giorno viene rimosso dal viaggio. Non si può annullare.',
|
||||
'dayplan.deleteDayEmpty': "Non c'è nulla di pianificato in questo giorno.",
|
||||
'dayplan.impactPlaces': 'Luoghi pianificati: {count}',
|
||||
'dayplan.impactPlacesHint': "Restano nell'elenco dei luoghi.",
|
||||
'dayplan.impactNotes': 'Note: {count}',
|
||||
'dayplan.impactTexts': 'Titoli e descrizioni del giorno: {count}',
|
||||
'dayplan.impactDeletedHint': 'Vengono eliminati anche questi.',
|
||||
'dayplan.impactBookings': 'Prenotazioni: {count}',
|
||||
'dayplan.impactStay': 'Soggiorno presso {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Restano in Prenotazioni, senza giorno.',
|
||||
'dayplan.deleteDayStayHint': 'Il check-in o il check-out cade in questo giorno, quindi il soggiorno viene annullato.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Il check-in o il check-out cade in questo giorno, quindi il soggiorno viene annullato insieme alla prenotazione «{booking}» e alla sua spesa.',
|
||||
'dayplan.deleteDayShift': 'Giorni successivi: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Ognuno passa alla data precedente.',
|
||||
'dayplan.deleteDayShiftBookingsHint':
|
||||
'Ognuno passa alla data precedente. Prenotazioni che si spostano con loro: {count}',
|
||||
'dayplan.deleteDayShrink': 'Il viaggio ora termina il {date}',
|
||||
'dayplan.deleteDayShrinkHint': "Non c'è alcun giorno senza data che possa prendere l'ultima data.",
|
||||
'dayplan.deleteDayLast': 'Un viaggio ha bisogno di almeno un giorno',
|
||||
'dayplan.daysOffline': 'Per modificare i giorni serve una connessione',
|
||||
'dayplan.deleteDaySuccess': 'Giorno eliminato',
|
||||
'dayplan.deleteDayError': 'Eliminazione del giorno non riuscita',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': '日付を並べ替え',
|
||||
'dayplan.reorderError': '日付の並べ替えに失敗しました',
|
||||
'dayplan.addDayError': '日付の追加に失敗しました',
|
||||
'dayplan.deleteDay': '日を削除',
|
||||
'dayplan.deleteDayTitle': '{day}を削除しますか?',
|
||||
'dayplan.deleteDayBody': 'この日は旅行から削除されます。元に戻すことはできません。',
|
||||
'dayplan.deleteDayEmpty': 'この日には何も予定されていません。',
|
||||
'dayplan.impactPlaces': '予定しているスポット: {count}',
|
||||
'dayplan.impactPlacesHint': 'スポット一覧には残ります。',
|
||||
'dayplan.impactNotes': 'メモ: {count}',
|
||||
'dayplan.impactTexts': '日のタイトルと説明: {count}',
|
||||
'dayplan.impactDeletedHint': 'これらも削除されます。',
|
||||
'dayplan.impactBookings': '予約: {count}',
|
||||
'dayplan.impactStay': '{name}での宿泊',
|
||||
'dayplan.deleteDayBookingsHint': '日付なしで予約に残ります。',
|
||||
'dayplan.deleteDayStayHint': 'この日にチェックインまたはチェックアウトするため、宿泊はキャンセルされます。',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'この日にチェックインまたはチェックアウトするため、宿泊は予約「{booking}」とその支出と一緒にキャンセルされます。',
|
||||
'dayplan.deleteDayShift': '以降の日: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'それぞれ1日前の日付になります。',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'それぞれ1日前の日付になります。一緒に移動する予約: {count}',
|
||||
'dayplan.deleteDayShrink': '旅行の終了日は{date}になります',
|
||||
'dayplan.deleteDayShrinkHint': '最後の日付を引き継ぐ日付なしの日がありません。',
|
||||
'dayplan.deleteDayLast': '旅行には少なくとも1日が必要です',
|
||||
'dayplan.daysOffline': '日の変更には接続が必要です',
|
||||
'dayplan.deleteDaySuccess': '日を削除しました',
|
||||
'dayplan.deleteDayError': '日の削除に失敗しました',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': '날짜 순서 변경',
|
||||
'dayplan.reorderError': '날짜 순서를 변경하지 못했습니다',
|
||||
'dayplan.addDayError': '날짜를 추가하지 못했습니다',
|
||||
'dayplan.deleteDay': '날짜 삭제',
|
||||
'dayplan.deleteDayTitle': '{day}을(를) 삭제할까요?',
|
||||
'dayplan.deleteDayBody': '이 날짜가 여행에서 제거됩니다. 되돌릴 수 없습니다.',
|
||||
'dayplan.deleteDayEmpty': '이 날짜에는 계획된 항목이 없습니다.',
|
||||
'dayplan.impactPlaces': '계획된 장소: {count}',
|
||||
'dayplan.impactPlacesHint': '장소 목록에는 그대로 남습니다.',
|
||||
'dayplan.impactNotes': '메모: {count}',
|
||||
'dayplan.impactTexts': '날짜 제목과 설명: {count}',
|
||||
'dayplan.impactDeletedHint': '함께 삭제됩니다.',
|
||||
'dayplan.impactBookings': '예약: {count}',
|
||||
'dayplan.impactStay': '{name} 숙박',
|
||||
'dayplan.deleteDayBookingsHint': '날짜 없이 예약에 남습니다.',
|
||||
'dayplan.deleteDayStayHint': '이 날짜에 체크인 또는 체크아웃하므로 숙박이 취소됩니다.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'이 날짜에 체크인 또는 체크아웃하므로 숙박이 예약 “{booking}” 및 해당 지출과 함께 취소됩니다.',
|
||||
'dayplan.deleteDayShift': '이후 날짜: {count}',
|
||||
'dayplan.deleteDayShiftHint': '각각 하루씩 앞당겨집니다.',
|
||||
'dayplan.deleteDayShiftBookingsHint': '각각 하루씩 앞당겨집니다. 함께 이동하는 예약: {count}',
|
||||
'dayplan.deleteDayShrink': '이제 여행이 {date}에 끝납니다',
|
||||
'dayplan.deleteDayShrinkHint': '마지막 날짜를 이어받을 날짜 없는 일정이 없습니다.',
|
||||
'dayplan.deleteDayLast': '여행에는 최소 하루가 필요합니다',
|
||||
'dayplan.daysOffline': '날짜를 변경하려면 연결이 필요합니다',
|
||||
'dayplan.deleteDaySuccess': '날짜가 삭제되었습니다',
|
||||
'dayplan.deleteDayError': '날짜를 삭제하지 못했습니다',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Dagen herordenen',
|
||||
'dayplan.reorderError': 'Dagen herordenen mislukt',
|
||||
'dayplan.addDayError': 'Dag toevoegen mislukt',
|
||||
'dayplan.deleteDay': 'Dag verwijderen',
|
||||
'dayplan.deleteDayTitle': '{day} verwijderen?',
|
||||
'dayplan.deleteDayBody': 'De dag wordt uit de reis verwijderd. Dit kan niet ongedaan worden gemaakt.',
|
||||
'dayplan.deleteDayEmpty': 'Er staat niets gepland op deze dag.',
|
||||
'dayplan.impactPlaces': 'Geplande plekken: {count}',
|
||||
'dayplan.impactPlacesHint': 'Ze blijven in de lijst met plekken.',
|
||||
'dayplan.impactNotes': 'Notities: {count}',
|
||||
'dayplan.impactTexts': 'Dagtitels en beschrijvingen: {count}',
|
||||
'dayplan.impactDeletedHint': 'Worden ook verwijderd.',
|
||||
'dayplan.impactBookings': 'Boekingen: {count}',
|
||||
'dayplan.impactStay': 'Verblijf in {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Ze blijven onder Boekingen, zonder dag.',
|
||||
'dayplan.deleteDayStayHint': 'Inchecken of uitchecken valt op deze dag, dus het verblijf wordt geannuleerd.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Inchecken of uitchecken valt op deze dag, dus het verblijf wordt geannuleerd, samen met de boeking ‘{booking}’ en de bijbehorende uitgave.',
|
||||
'dayplan.deleteDayShift': 'Latere dagen: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Elke dag schuift één datum naar voren.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'Elke dag schuift één datum naar voren. Boekingen die meeschuiven: {count}',
|
||||
'dayplan.deleteDayShrink': 'De reis eindigt nu op {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Er is geen dag zonder datum die de laatste datum kan overnemen.',
|
||||
'dayplan.deleteDayLast': 'Een reis heeft minstens één dag nodig',
|
||||
'dayplan.daysOffline': 'Dagen wijzigen vraagt een verbinding',
|
||||
'dayplan.deleteDaySuccess': 'Dag verwijderd',
|
||||
'dayplan.deleteDayError': 'Dag verwijderen mislukt',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -90,5 +90,30 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Zmień kolejność dni',
|
||||
'dayplan.reorderError': 'Nie udało się zmienić kolejności dni',
|
||||
'dayplan.addDayError': 'Nie udało się dodać dnia',
|
||||
'dayplan.deleteDay': 'Usuń dzień',
|
||||
'dayplan.deleteDayTitle': 'Usunąć {day}?',
|
||||
'dayplan.deleteDayBody': 'Dzień zostanie usunięty z podróży. Tej operacji nie można cofnąć.',
|
||||
'dayplan.deleteDayEmpty': 'Na ten dzień nic nie zaplanowano.',
|
||||
'dayplan.impactPlaces': 'Zaplanowane miejsca: {count}',
|
||||
'dayplan.impactPlacesHint': 'Pozostają na liście miejsc.',
|
||||
'dayplan.impactNotes': 'Notatki: {count}',
|
||||
'dayplan.impactTexts': 'Tytuły i opisy dnia: {count}',
|
||||
'dayplan.impactDeletedHint': 'Również zostaną usunięte.',
|
||||
'dayplan.impactBookings': 'Rezerwacje: {count}',
|
||||
'dayplan.impactStay': 'Nocleg w {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Pozostają w Rezerwacjach, bez dnia.',
|
||||
'dayplan.deleteDayStayHint': 'Zameldowanie lub wymeldowanie przypada na ten dzień, więc nocleg zostanie anulowany.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Zameldowanie lub wymeldowanie przypada na ten dzień, więc nocleg zostanie anulowany razem z rezerwacją „{booking}” i jej wydatkiem.',
|
||||
'dayplan.deleteDayShift': 'Późniejsze dni: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Każdy przesuwa się o jedną datę wcześniej.',
|
||||
'dayplan.deleteDayShiftBookingsHint':
|
||||
'Każdy przesuwa się o jedną datę wcześniej. Rezerwacje przesuwane razem z nimi: {count}',
|
||||
'dayplan.deleteDayShrink': 'Podróż kończy się teraz {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Nie ma dnia bez daty, który mógłby przejąć ostatnią datę.',
|
||||
'dayplan.deleteDayLast': 'Podróż musi mieć co najmniej jeden dzień',
|
||||
'dayplan.daysOffline': 'Zmiana dni wymaga połączenia',
|
||||
'dayplan.deleteDaySuccess': 'Dzień usunięty',
|
||||
'dayplan.deleteDayError': 'Nie udało się usunąć dnia',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Изменить порядок дней',
|
||||
'dayplan.reorderError': 'Не удалось изменить порядок дней',
|
||||
'dayplan.addDayError': 'Не удалось добавить день',
|
||||
'dayplan.deleteDay': 'Удалить день',
|
||||
'dayplan.deleteDayTitle': 'Удалить {day}?',
|
||||
'dayplan.deleteDayBody': 'День будет удалён из поездки. Это действие нельзя отменить.',
|
||||
'dayplan.deleteDayEmpty': 'На этот день ничего не запланировано.',
|
||||
'dayplan.impactPlaces': 'Запланированные места: {count}',
|
||||
'dayplan.impactPlacesHint': 'Они останутся в списке мест.',
|
||||
'dayplan.impactNotes': 'Заметки: {count}',
|
||||
'dayplan.impactTexts': 'Названия и описания дня: {count}',
|
||||
'dayplan.impactDeletedHint': 'Тоже будут удалены.',
|
||||
'dayplan.impactBookings': 'Бронирования: {count}',
|
||||
'dayplan.impactStay': 'Проживание в {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Они останутся в Бронированиях, без дня.',
|
||||
'dayplan.deleteDayStayHint': 'Заезд или выезд приходится на этот день, поэтому проживание отменяется.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Заезд или выезд приходится на этот день, поэтому проживание отменяется вместе с бронированием «{booking}» и его расходом.',
|
||||
'dayplan.deleteDayShift': 'Последующие дни: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Каждый сдвигается на одну дату раньше.',
|
||||
'dayplan.deleteDayShiftBookingsHint':
|
||||
'Каждый сдвигается на одну дату раньше. Бронирования, которые сдвигаются вместе с ними: {count}',
|
||||
'dayplan.deleteDayShrink': 'Теперь поездка заканчивается {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Нет дня без даты, который мог бы занять последнюю дату.',
|
||||
'dayplan.deleteDayLast': 'В поездке должен быть хотя бы один день',
|
||||
'dayplan.daysOffline': 'Для изменения дней нужно подключение',
|
||||
'dayplan.deleteDaySuccess': 'День удалён',
|
||||
'dayplan.deleteDayError': 'Не удалось удалить день',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -90,5 +90,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Sortera om dagar',
|
||||
'dayplan.reorderError': 'Kunde inte sortera om dagar',
|
||||
'dayplan.addDayError': 'Kunde inte lägga till dag',
|
||||
'dayplan.deleteDay': 'Ta bort dag',
|
||||
'dayplan.deleteDayTitle': 'Ta bort {day}?',
|
||||
'dayplan.deleteDayBody': 'Dagen tas bort från resan. Det går inte att ångra.',
|
||||
'dayplan.deleteDayEmpty': 'Inget är planerat den här dagen.',
|
||||
'dayplan.impactPlaces': 'Planerade platser: {count}',
|
||||
'dayplan.impactPlacesHint': 'De ligger kvar i platslistan.',
|
||||
'dayplan.impactNotes': 'Anteckningar: {count}',
|
||||
'dayplan.impactTexts': 'Dagtitlar och beskrivningar: {count}',
|
||||
'dayplan.impactDeletedHint': 'Tas också bort.',
|
||||
'dayplan.impactBookings': 'Bokningar: {count}',
|
||||
'dayplan.impactStay': 'Boende på {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'De ligger kvar under Bokningar, utan dag.',
|
||||
'dayplan.deleteDayStayHint': 'Incheckning eller utcheckning sker den här dagen, så boendet avbokas.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Incheckning eller utcheckning sker den här dagen, så boendet avbokas tillsammans med bokningen ”{booking}” och dess utgift.',
|
||||
'dayplan.deleteDayShift': 'Senare dagar: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Var och en flyttas ett datum tidigare.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'Var och en flyttas ett datum tidigare. Bokningar som följer med: {count}',
|
||||
'dayplan.deleteDayShrink': 'Resan slutar nu {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Det finns ingen dag utan datum som kan ta över det sista datumet.',
|
||||
'dayplan.deleteDayLast': 'En resa behöver minst en dag',
|
||||
'dayplan.daysOffline': 'Att ändra dagar kräver en anslutning',
|
||||
'dayplan.deleteDaySuccess': 'Dagen togs bort',
|
||||
'dayplan.deleteDayError': 'Kunde inte ta bort dagen',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Günleri yeniden sırala',
|
||||
'dayplan.reorderError': 'Günler yeniden sıralanamadı',
|
||||
'dayplan.addDayError': 'Gün eklenemedi',
|
||||
'dayplan.deleteDay': 'Günü sil',
|
||||
'dayplan.deleteDayTitle': '{day} silinsin mi?',
|
||||
'dayplan.deleteDayBody': 'Gün seyahatten kaldırılır. Bu işlem geri alınamaz.',
|
||||
'dayplan.deleteDayEmpty': 'Bu gün için planlanmış bir şey yok.',
|
||||
'dayplan.impactPlaces': 'Planlanan yerler: {count}',
|
||||
'dayplan.impactPlacesHint': 'Yer listesinde kalırlar.',
|
||||
'dayplan.impactNotes': 'Notlar: {count}',
|
||||
'dayplan.impactTexts': 'Gün başlıkları ve açıklamaları: {count}',
|
||||
'dayplan.impactDeletedHint': 'Bunlar da silinir.',
|
||||
'dayplan.impactBookings': 'Rezervasyonlar: {count}',
|
||||
'dayplan.impactStay': '{name} konaklaması',
|
||||
'dayplan.deleteDayBookingsHint': 'Rezervasyonlar altında, bir güne bağlı olmadan kalırlar.',
|
||||
'dayplan.deleteDayStayHint': 'Giriş veya çıkış bu güne denk geldiği için konaklama iptal edilir.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Giriş veya çıkış bu güne denk geldiği için konaklama, “{booking}” rezervasyonu ve harcamasıyla birlikte iptal edilir.',
|
||||
'dayplan.deleteDayShift': 'Sonraki günler: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Her biri bir tarih öne kayar.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'Her biri bir tarih öne kayar. Onlarla birlikte kayan rezervasyonlar: {count}',
|
||||
'dayplan.deleteDayShrink': 'Seyahat artık {date} tarihinde bitiyor',
|
||||
'dayplan.deleteDayShrinkHint': 'Son tarihi devralacak tarihsiz bir gün yok.',
|
||||
'dayplan.deleteDayLast': 'Bir seyahatin en az bir günü olmalı',
|
||||
'dayplan.daysOffline': 'Günleri değiştirmek için bağlantı gerekir',
|
||||
'dayplan.deleteDaySuccess': 'Gün silindi',
|
||||
'dayplan.deleteDayError': 'Gün silinemedi',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,30 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Змінити порядок днів',
|
||||
'dayplan.reorderError': 'Не вдалося змінити порядок днів',
|
||||
'dayplan.addDayError': 'Не вдалося додати день',
|
||||
'dayplan.deleteDay': 'Видалити день',
|
||||
'dayplan.deleteDayTitle': 'Видалити {day}?',
|
||||
'dayplan.deleteDayBody': 'День буде видалено з подорожі. Цю дію не можна скасувати.',
|
||||
'dayplan.deleteDayEmpty': 'На цей день нічого не заплановано.',
|
||||
'dayplan.impactPlaces': 'Заплановані місця: {count}',
|
||||
'dayplan.impactPlacesHint': 'Вони залишаться у списку місць.',
|
||||
'dayplan.impactNotes': 'Нотатки: {count}',
|
||||
'dayplan.impactTexts': 'Назви й описи дня: {count}',
|
||||
'dayplan.impactDeletedHint': 'Теж будуть видалені.',
|
||||
'dayplan.impactBookings': 'Бронювання: {count}',
|
||||
'dayplan.impactStay': 'Проживання в {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Вони залишаться в Бронюваннях, без дня.',
|
||||
'dayplan.deleteDayStayHint': 'Заїзд або виїзд припадає на цей день, тому проживання скасовується.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Заїзд або виїзд припадає на цей день, тому проживання скасовується разом із бронюванням «{booking}» і його витратою.',
|
||||
'dayplan.deleteDayShift': 'Наступні дні: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Кожен зсувається на одну дату раніше.',
|
||||
'dayplan.deleteDayShiftBookingsHint':
|
||||
'Кожен зсувається на одну дату раніше. Бронювання, що зсуваються разом із ними: {count}',
|
||||
'dayplan.deleteDayShrink': 'Тепер подорож закінчується {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Немає дня без дати, який міг би зайняти останню дату.',
|
||||
'dayplan.deleteDayLast': 'Подорож має містити щонайменше один день',
|
||||
'dayplan.daysOffline': 'Для зміни днів потрібне з’єднання',
|
||||
'dayplan.deleteDaySuccess': 'День видалено',
|
||||
'dayplan.deleteDayError': 'Не вдалося видалити день',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -89,5 +89,29 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': 'Sắp xếp lại ngày',
|
||||
'dayplan.reorderError': 'Không thể sắp xếp lại ngày',
|
||||
'dayplan.addDayError': 'Không thể thêm ngày',
|
||||
'dayplan.deleteDay': 'Xóa ngày',
|
||||
'dayplan.deleteDayTitle': 'Xóa {day}?',
|
||||
'dayplan.deleteDayBody': 'Ngày này sẽ bị xóa khỏi chuyến đi. Không thể hoàn tác.',
|
||||
'dayplan.deleteDayEmpty': 'Không có gì được lên kế hoạch cho ngày này.',
|
||||
'dayplan.impactPlaces': 'Địa điểm đã lên kế hoạch: {count}',
|
||||
'dayplan.impactPlacesHint': 'Chúng vẫn nằm trong danh sách địa điểm.',
|
||||
'dayplan.impactNotes': 'Ghi chú: {count}',
|
||||
'dayplan.impactTexts': 'Tiêu đề và mô tả ngày: {count}',
|
||||
'dayplan.impactDeletedHint': 'Cũng bị xóa.',
|
||||
'dayplan.impactBookings': 'Đặt chỗ: {count}',
|
||||
'dayplan.impactStay': 'Lưu trú tại {name}',
|
||||
'dayplan.deleteDayBookingsHint': 'Chúng vẫn nằm trong Đặt chỗ, không gắn với ngày nào.',
|
||||
'dayplan.deleteDayStayHint': 'Nhận phòng hoặc trả phòng rơi vào ngày này, nên chỗ lưu trú bị hủy.',
|
||||
'dayplan.deleteDayStayBookedHint':
|
||||
'Nhận phòng hoặc trả phòng rơi vào ngày này, nên chỗ lưu trú bị hủy cùng với đặt chỗ “{booking}” và chi phí của nó.',
|
||||
'dayplan.deleteDayShift': 'Các ngày sau: {count}',
|
||||
'dayplan.deleteDayShiftHint': 'Mỗi ngày được chuyển sớm hơn một ngày.',
|
||||
'dayplan.deleteDayShiftBookingsHint': 'Mỗi ngày được chuyển sớm hơn một ngày. Đặt chỗ di chuyển theo: {count}',
|
||||
'dayplan.deleteDayShrink': 'Chuyến đi giờ kết thúc vào {date}',
|
||||
'dayplan.deleteDayShrinkHint': 'Không còn ngày nào chưa có ngày tháng để nhận ngày cuối cùng.',
|
||||
'dayplan.deleteDayLast': 'Một chuyến đi cần ít nhất một ngày',
|
||||
'dayplan.daysOffline': 'Cần có kết nối để thay đổi ngày',
|
||||
'dayplan.deleteDaySuccess': 'Đã xóa ngày',
|
||||
'dayplan.deleteDayError': 'Không thể xóa ngày',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -88,5 +88,28 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': '重新排序日期',
|
||||
'dayplan.reorderError': '重新排序日期失敗',
|
||||
'dayplan.addDayError': '新增日期失敗',
|
||||
'dayplan.deleteDay': '刪除這一天',
|
||||
'dayplan.deleteDayTitle': '刪除 {day}?',
|
||||
'dayplan.deleteDayBody': '這一天會從行程中移除。此操作無法復原。',
|
||||
'dayplan.deleteDayEmpty': '這一天沒有任何安排。',
|
||||
'dayplan.impactPlaces': '已安排的地點:{count}',
|
||||
'dayplan.impactPlacesHint': '它們會保留在地點清單中。',
|
||||
'dayplan.impactNotes': '筆記:{count}',
|
||||
'dayplan.impactTexts': '日期標題和說明:{count}',
|
||||
'dayplan.impactDeletedHint': '也會一併刪除。',
|
||||
'dayplan.impactBookings': '預訂:{count}',
|
||||
'dayplan.impactStay': '住宿:{name}',
|
||||
'dayplan.deleteDayBookingsHint': '它們會保留在「預訂」中,但不再屬於任何一天。',
|
||||
'dayplan.deleteDayStayHint': '入住或退房在這一天,因此住宿會被取消。',
|
||||
'dayplan.deleteDayStayBookedHint': '入住或退房在這一天,因此住宿會被取消,同時取消預訂「{booking}」及其費用。',
|
||||
'dayplan.deleteDayShift': '之後的日期:{count}',
|
||||
'dayplan.deleteDayShiftHint': '每一天都會提前一個日期。',
|
||||
'dayplan.deleteDayShiftBookingsHint': '每一天都會提前一個日期。隨之移動的預訂:{count}',
|
||||
'dayplan.deleteDayShrink': '行程現在結束於 {date}',
|
||||
'dayplan.deleteDayShrinkHint': '沒有未設定日期的一天可以接替最後一個日期。',
|
||||
'dayplan.deleteDayLast': '行程至少需要一天',
|
||||
'dayplan.daysOffline': '變更日期需要網路連線',
|
||||
'dayplan.deleteDaySuccess': '已刪除這一天',
|
||||
'dayplan.deleteDayError': '刪除這一天失敗',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -88,5 +88,28 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.reorderUndo': '调整日期顺序',
|
||||
'dayplan.reorderError': '调整日期顺序失败',
|
||||
'dayplan.addDayError': '添加一天失败',
|
||||
'dayplan.deleteDay': '删除这一天',
|
||||
'dayplan.deleteDayTitle': '删除 {day}?',
|
||||
'dayplan.deleteDayBody': '这一天会从行程中移除。此操作无法撤销。',
|
||||
'dayplan.deleteDayEmpty': '这一天没有任何安排。',
|
||||
'dayplan.impactPlaces': '已安排的地点:{count}',
|
||||
'dayplan.impactPlacesHint': '它们会保留在地点列表中。',
|
||||
'dayplan.impactNotes': '备注:{count}',
|
||||
'dayplan.impactTexts': '日期标题和描述:{count}',
|
||||
'dayplan.impactDeletedHint': '也会一并删除。',
|
||||
'dayplan.impactBookings': '预订:{count}',
|
||||
'dayplan.impactStay': '住宿:{name}',
|
||||
'dayplan.deleteDayBookingsHint': '它们会保留在“预订”中,但不再属于任何一天。',
|
||||
'dayplan.deleteDayStayHint': '入住或退房在这一天,因此住宿会被取消。',
|
||||
'dayplan.deleteDayStayBookedHint': '入住或退房在这一天,因此住宿会被取消,同时取消预订“{booking}”及其费用。',
|
||||
'dayplan.deleteDayShift': '之后的日期:{count}',
|
||||
'dayplan.deleteDayShiftHint': '每一天都会提前一个日期。',
|
||||
'dayplan.deleteDayShiftBookingsHint': '每一天都会提前一个日期。随之移动的预订:{count}',
|
||||
'dayplan.deleteDayShrink': '行程现在结束于 {date}',
|
||||
'dayplan.deleteDayShrinkHint': '没有未设日期的一天可以接替最后一个日期。',
|
||||
'dayplan.deleteDayLast': '行程至少需要一天',
|
||||
'dayplan.daysOffline': '更改日期需要网络连接',
|
||||
'dayplan.deleteDaySuccess': '已删除这一天',
|
||||
'dayplan.deleteDayError': '删除这一天失败',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -77,7 +77,7 @@ At the top of the Day Plan sidebar:
|
||||
- **Export** — opens the export dialog, which holds every format in three groups: **Document** (a PDF of the full trip plan, see [PDF-Export](PDF-Export)), **Calendar** (a `.ics` file for import into calendar apps, plus a subscribable calendar feed for members who can manage share links) and **Maps & GPS** (GPX — whole trip, places only, or days as routes).
|
||||
- **Expand / Collapse all** — toggles all day sections open or closed at once.
|
||||
- **Undo** — reverses the last drag, reorder, or assign action.
|
||||
- **Reorder days** — reorder the days of the trip or insert a new one. A day's places, notes and bookings move with it. Shown to members who can edit days.
|
||||
- **Reorder days**: reorder the days of the trip, add a day, or delete one. A day's places, notes and bookings move with it. Shown to members who can edit days. See [Deleting a day](#deleting-a-day).
|
||||
- **Show all booking routes** — draws the connection of every routable booking on the map at once. Shown once the trip has at least one routable booking.
|
||||
|
||||
Route controls appear at the bottom of a day section, after the place list, and only for the day you have selected — click a day header to select it. The row only appears on a day that can actually be routed: two or more places on the day, a single located place that accommodation optimization can bookend with a hotel, or a transfer day where you check out of one hotel and into another. On a phone the same controls sit in the **Daily Overview** sheet instead, opened from the pill above the plan timeline, and appear there when the day has two located places, or one located place plus a hotel to start from.
|
||||
@@ -88,4 +88,16 @@ Route controls appear at the bottom of a day section, after the place list, and
|
||||
- **Optimize** — reorders the day's places into the shortest route. See [Route-Optimization](Route-Optimization).
|
||||
- **Travel mode** — Driving or Walking for that day, plus any travel mode a plugin adds.
|
||||
|
||||
## Deleting a day
|
||||
|
||||
Each row of the **Reorder days** dialog has a delete button; on a phone it sits next to the arrows in the day sheet. Before anything is deleted, TREK asks and lists what goes with the day:
|
||||
|
||||
- **Planned places** stay in the place list of the trip. Only their spot on this day goes.
|
||||
- **Notes**, the day title and the day description are deleted.
|
||||
- **Bookings** on the day stay under Bookings, without a day, and keep their date.
|
||||
- **A stay that checks in or out on the day** is cancelled, together with its booking and the expense of that booking. The list shows it in red. A stay that only runs across the day is kept.
|
||||
- **The days after it** move up one place. On a trip with dates the dates stay where they are, so every later day, and the bookings on it, takes the date one slot earlier. A day without a date at the end takes over the last date; when there is none, the trip ends one day earlier.
|
||||
|
||||
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.
|
||||
|
||||
**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)
|
||||
@@ -79,7 +79,7 @@ Requires `trips:read` or `trips:write` scope.
|
||||
|---|---|
|
||||
| `update_day` | Set or clear a day's title. |
|
||||
| `create_day` | Add a new day to a trip with optional date and notes. |
|
||||
| `delete_day` | Delete a day from a trip. |
|
||||
| `delete_day` | Delete a day from a trip. Its places stay in the place list, its notes and texts go, and bookings on it stay without a day. A stay that checks in or out on the day is cancelled with its booking and expense. Later days move up one place; on a dated trip they and their bookings take the date one slot earlier, and the trip ends a day earlier when no day without a date is left to take the last date. The last day of a trip cannot be deleted. |
|
||||
| `reorder_days` | Reorder whole days by listing every day ID of the trip in the desired order. Each day keeps its places, notes, stays and bookings; on a dated trip the dates stay pinned to their slots, so the content moves across them. For places inside one day use `reorder_day_assignments`. Requires `trips:write`. |
|
||||
| `set_day_default_transport_mode` | Set the whole-day default travel mode. Per-leg modes still override it. Pass `null` to clear. |
|
||||
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. Requires `places:write`. |
|
||||
|
||||
Reference in New Issue
Block a user