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:
Maurice
2026-09-23 18:42:02 +02:00
parent 436f0740fb
commit 2f6f921aef
78 changed files with 3086 additions and 170 deletions
+5 -2
View File
@@ -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" />);
+20 -12
View File
@@ -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>
</>
)
}
+54 -1
View File
@@ -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.
+16 -1
View File
@@ -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()
})
})
+20 -1
View File
@@ -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,
+33 -2
View File
@@ -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()
})
})
+18 -2
View File
@@ -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 }
},
}
+72 -2
View File
@@ -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');
});
});
});
+48
View File
@@ -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)
},
})
+8 -12
View File
@@ -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') {
+124
View File
@@ -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()
})
})
+136
View File
@@ -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
}
+56
View File
@@ -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' }])
})
})
+97
View File
@@ -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
}
+22
View File
@@ -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')
})
})
+23
View File
@@ -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 })
}
+10 -1
View File
@@ -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', () => {
+233
View File
@@ -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) } : {}),
};
}
+18 -4
View File
@@ -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 };
}
}
+16 -5
View File
@@ -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 });
}
+13 -2
View File
@@ -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 {}
+12 -2
View File
@@ -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 };
}
}
+8 -2
View File
@@ -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 });
}
// -------------------------------------------------------------------------
+39
View File
@@ -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
`;
+5 -29
View File
@@ -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;
+32 -3
View File
@@ -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 () => {
+4 -1
View File
@@ -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),
+2 -1
View File
@@ -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),
+1 -1
View File
@@ -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()),
+1
View File
@@ -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',
+70 -1
View File
@@ -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] }],
]);
});
});
+60 -14
View File
@@ -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'],
]);
});
});
});
+15 -6
View File
@@ -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),
+24
View File
@@ -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;
+24
View File
@@ -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;
+25 -1
View File
@@ -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;
+25
View File
@@ -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;
+25
View File
@@ -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;
+24
View File
@@ -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;
+24
View File
@@ -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;
+26 -2
View File
@@ -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;
+25
View File
@@ -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;
+25
View File
@@ -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;
+24
View File
@@ -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;
+26 -1
View File
@@ -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;
+24
View File
@@ -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;
+24
View File
@@ -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;
+24
View File
@@ -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;
+25
View File
@@ -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;
+25
View File
@@ -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;
+24
View File
@@ -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;
+24
View File
@@ -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;
+25
View File
@@ -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;
+24
View File
@@ -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;
+23
View File
@@ -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;
+23
View File
@@ -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;
+13 -1
View File
@@ -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)
+1 -1
View File
@@ -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`. |