feat(dayplan): a day can be added with the next date, which extends the trip by one

On a trip with dates the reorder dialog and the days sheet on a phone now offer two ways to add a day: a day without a date at the end as before, or the calendar day after the last date, named on its button, which extends the trip by one day. The new day goes right behind the last dated day, so the days without a date move one place back with their plans, and no existing day or booking changes its date.

The server does it in one transaction behind a new dated flag on the day create contract, used by REST, the create_day tool and the plugin RPC alike. It moves the road trip boundaries of the days behind the new one, and announces the day with day:reordered and the grown trip with trip:updated; an insert at a position announces its trip now as well. The next date and the day limit come from one shared rule, so the button never promises a date the server would not give.

On the client the add is online only and runs one at a time, so a double click cannot grow the trip by two days. A wiring test that picked the stay question by its position now finds it by its handler, since the delete question sits in front of it.
This commit is contained in:
Maurice
2026-09-23 19:16:04 +02:00
parent 2f6f921aef
commit b56f335978
64 changed files with 1745 additions and 113 deletions
+6 -1
View File
@@ -1,4 +1,4 @@
// FE-APISURF-001 to FE-APISURF-057 // FE-APISURF-001 to FE-APISURF-058
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios' import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw' import { http, HttpResponse } from 'msw'
@@ -572,6 +572,11 @@ describe('client > request payloads', () => {
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] }) expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
}) })
it('FE-APISURF-058: a dated day is asked for with the dated flag alone', async () => {
const rec = await traceOne(() => daysApi.create(1, { dated: true }))
expect(rec).toMatchObject({ method: 'POST', url: '/api/trips/1/days', body: { dated: true } })
})
it('FE-APISURF-023: user-id collections are sent as user_ids', async () => { it('FE-APISURF-023: user-id collections are sent as user_ids', async () => {
expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] }) expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] })
expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] }) expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] })
+5 -2
View File
@@ -1,7 +1,7 @@
import { isEffectivelyOffline } from '../sync/networkMode' import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios' import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod' import type { z } from 'zod'
import type { Place, Trip } from '../types' import type { Day, Place, Trip } from '../types'
import type { TransitProvider } from '@trek/shared' import type { TransitProvider } from '@trek/shared'
import { randomId } from '../utils/randomId' import { randomId } from '../utils/randomId'
import { import {
@@ -434,7 +434,10 @@ export const tripsApi = {
export const daysApi = { export const daysApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data), list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), // `trip` comes along when the new day changed the trip itself: a dated day
// (`dated: true`) moved its end date, an insert at a position grew it by a day.
create: (tripId: number | string, data: DayCreateRequest): Promise<{ day: Day; trip?: Trip }> =>
apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), 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. // 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), updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
@@ -0,0 +1,100 @@
import { useId, useState } from 'react'
import { CalendarPlus, Plus } from 'lucide-react'
import { formatDate } from '../../utils/formatters'
import type { DayAddControls } from '../../utils/dayAdd'
interface DayAddFooterProps {
/** Without it, or on a trip without dates, the footer keeps its single "Add day" button. */
dayAdd?: DayAddControls
onAddDay: () => void
onClose: () => void
t: (key: string, params?: Record<string, string | number>) => string
locale: string
}
const outlineBtn =
'flex items-center justify-center gap-1.5 rounded-lg border border-edge py-2 text-body font-medium transition-colors hover:bg-surface-hover focus-visible:bg-surface-hover disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent'
const primaryBtn =
'flex items-center justify-center gap-1.5 rounded-lg bg-accent py-2 text-body font-medium text-accent-text transition-colors hover:bg-accent-hover disabled:cursor-default disabled:opacity-40 disabled:hover:bg-accent'
/**
* The foot of the reorder dialog: close it, or add a day.
*
* On a trip with dates there are two ways to add one, side by side. A day
* without a date goes to the end and leaves the trip dates alone; the next
* calendar day, named on its button, extends the trip by one. The line under
* them says what the button in view does, and switches while the pointer or the
* focus rests on the other one. When no day can be added it says why instead.
*/
export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFooterProps) {
const [aboutUndated, setAboutUndated] = useState(false)
const hintId = useId()
const busy = dayAdd?.busy ?? false
const blocked = dayAdd?.blocked ?? null
const addOff = busy || !!blocked
const closeButton = (
<button type="button" onClick={onClose} className={`${outlineBtn} flex-shrink-0 px-4 text-content-muted`}>
{t('common.close')}
</button>
)
if (!dayAdd?.nextDate) {
return (
<div className="flex items-center justify-between gap-3">
{closeButton}
<div className="flex min-w-0 items-center gap-3">
{blocked && <span className="min-w-0 text-caption text-content-faint">{blocked}</span>}
<button type="button" onClick={onAddDay} disabled={addOff} className={`${primaryBtn} flex-shrink-0 px-4`}>
<Plus size={15} strokeWidth={2} />
{t('dayplan.addDay')}
</button>
</div>
</div>
)
}
const date = formatDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate
const hint = blocked
?? (aboutUndated ? t('dayplan.addUndatedDayHint') : dayAdd.datedBlocked ?? t('dayplan.addDatedDayHint', { date }))
const pointAtUndated = {
onMouseEnter: () => setAboutUndated(true),
onMouseLeave: () => setAboutUndated(false),
onFocus: () => setAboutUndated(true),
onBlur: () => setAboutUndated(false),
}
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={onAddDay}
disabled={addOff}
aria-describedby={hintId}
className={`${outlineBtn} min-w-0 px-3 text-center leading-tight text-content-secondary`}
{...pointAtUndated}
>
<Plus size={15} strokeWidth={2} className="flex-shrink-0" />
{t('dayplan.addUndatedDay')}
</button>
<button
type="button"
onClick={dayAdd.onAddDated}
disabled={addOff || !!dayAdd.datedBlocked}
aria-describedby={hintId}
className={`${primaryBtn} min-w-0 px-3 text-center leading-tight`}
>
<CalendarPlus size={15} strokeWidth={2} className="flex-shrink-0" />
{t('dayplan.addDatedDay', { date })}
</button>
</div>
<div className="flex items-center justify-between gap-3">
<p id={hintId} aria-live="polite" className="m-0 min-w-0 text-caption text-content-faint">
{hint}
</p>
{closeButton}
</div>
</div>
)
}
@@ -56,6 +56,7 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal' import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay' import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay'
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { DayAddControls } from '../../utils/dayAdd'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation' import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
@@ -76,6 +77,8 @@ interface DayPlanSidebarProps {
onReorder: (dayId: number, orderedIds: number[]) => void onReorder: (dayId: number, orderedIds: number[]) => void
onReorderDays?: (orderedIds: number[]) => void onReorderDays?: (orderedIds: number[]) => void
onAddDay?: (position?: number) => void onAddDay?: (position?: number) => void
/** The planner's add controls, for the second "Add with date" button on a trip with dates. */
dayAdd?: DayAddControls
/** Asks to delete a day from the reorder dialog; the planner owns the question. */ /** Asks to delete a day from the reorder dialog; the planner owns the question. */
onDeleteDay?: (dayId: number) => void onDeleteDay?: (dayId: number) => void
/** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */ /** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */
@@ -148,7 +151,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
trip, days, places, categories, assignments, trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId, selectedAssignmentId, selectedDayId, selectedPlaceId, selectedAssignmentId,
onSelectDay, onPlaceClick, onDayDetail, accommodations = [], onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
onReorder, onReorderDays, onAddDay, onDeleteDay, onReorder, onReorderDays, onAddDay, dayAdd, onDeleteDay,
onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace, onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [], reservations = [],
visibleConnectionIds = [], visibleConnectionIds = [],
@@ -1131,6 +1134,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onReorder, onReorder,
onReorderDays, onReorderDays,
onAddDay, onAddDay,
dayAdd,
onDeleteDay, onDeleteDay,
onAssignToDay, onAssignToDay,
onRemoveAssignment, onRemoveAssignment,
@@ -1334,6 +1338,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onReorder, onReorder,
onReorderDays, onReorderDays,
onAddDay, onAddDay,
dayAdd,
onDeleteDay, onDeleteDay,
onAssignToDay, onAssignToDay,
onRemoveAssignment, onRemoveAssignment,
@@ -1594,6 +1599,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
canManageShare={canManageShare} canManageShare={canManageShare}
onReorderDays={onReorderDays} onReorderDays={onReorderDays}
onAddDay={onAddDay} onAddDay={onAddDay}
dayAdd={dayAdd}
onDeleteDay={onDeleteDay} onDeleteDay={onDeleteDay}
/> />
@@ -1,4 +1,4 @@
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-024 // FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-025
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF' import { downloadTripPDF } from '../PDF/TripPDF'
@@ -243,6 +243,23 @@ describe('DayPlanSidebarToolbar', () => {
expect(onDeleteDay).toHaveBeenCalledWith(11) expect(onDeleteDay).toHaveBeenCalledWith(11)
}) })
it('FE-PLANNER-DPTOOLBAR-025: the popup gets the planner add controls, and its dated button adds the next date', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 })]
const onAddDated = vi.fn()
const onAddDay = vi.fn()
render(<DayPlanSidebarToolbar {...makeProps({
days, canEditDays: true, onReorderDays: vi.fn(), onAddDay,
dayAdd: { nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated },
})} />)
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
await user.click(screen.getByRole('button', { name: /^dayplan\.addDatedDay/ }))
expect(onAddDated).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'dayplan.addUndatedDay' }))
// The popup asks for an append, never a position.
expect(onAddDay).toHaveBeenCalledWith()
})
it('FE-PLANNER-DPTOOLBAR-021: the reorder button is hidden without edit rights or without days', () => { it('FE-PLANNER-DPTOOLBAR-021: the reorder button is hidden without edit rights or without days', () => {
const days = [buildDay({ id: 10 })] const days = [buildDay({ id: 10 })]
const { rerender } = render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: false, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />) const { rerender } = render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: false, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
@@ -5,6 +5,7 @@ import Tooltip from '../shared/Tooltip'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { TripExportModal } from './TripExportModal' import { TripExportModal } from './TripExportModal'
import { isRoutableReservation } from '../../utils/reservationRoutes' import { isRoutableReservation } from '../../utils/reservationRoutes'
import type { DayAddControls } from '../../utils/dayAdd'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
interface DayPlanSidebarToolbarProps { interface DayPlanSidebarToolbarProps {
@@ -37,6 +38,8 @@ interface DayPlanSidebarToolbarProps {
canManageShare?: boolean canManageShare?: boolean
onReorderDays?: (orderedIds: number[]) => void onReorderDays?: (orderedIds: number[]) => void
onAddDay?: (position?: number) => void onAddDay?: (position?: number) => void
/** The planner's add controls: on a trip with dates the dialog offers the next date as well. */
dayAdd?: DayAddControls
/** Asks to delete a day from the reorder dialog; without it the dialog has no delete buttons. */ /** Asks to delete a day from the reorder dialog; without it the dialog has no delete buttons. */
onDeleteDay?: (dayId: number) => void onDeleteDay?: (dayId: number) => void
} }
@@ -46,7 +49,7 @@ export function DayPlanSidebarToolbar({
allConnectionsShown = false, onToggleAllConnections, allConnectionsShown = false, onToggleAllConnections,
t, locale, toast, t, locale, toast,
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel, expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
canEditDays, canManageShare = true, onReorderDays, onAddDay, onDeleteDay, canEditDays, canManageShare = true, onReorderDays, onAddDay, dayAdd, onDeleteDay,
}: DayPlanSidebarToolbarProps) { }: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false) const [reorderOpen, setReorderOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false)
@@ -199,6 +202,7 @@ export function DayPlanSidebarToolbar({
locale={locale} locale={locale}
onReorder={onReorderDays} onReorder={onReorderDays}
onAddDay={() => onAddDay()} onAddDay={() => onAddDay()}
dayAdd={dayAdd}
onDeleteDay={onDeleteDay} onDeleteDay={onDeleteDay}
onClose={() => setReorderOpen(false)} onClose={() => setReorderOpen(false)}
/> />
@@ -1,9 +1,10 @@
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-016 // FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-021
import { render, screen, fireEvent } from '../../../tests/helpers/render' import { render, screen, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { buildDay } from '../../../tests/helpers/factories' import { buildDay } from '../../../tests/helpers/factories'
import { DayReorderPopup } from './DayReorderPopup' import { DayReorderPopup } from './DayReorderPopup'
import { setForcedOffline } from '../../sync/networkMode' import { setForcedOffline } from '../../sync/networkMode'
import type { DayAddControls } from '../../utils/dayAdd'
import type { Day } from '../../types' import type { Day } from '../../types'
// The component takes `t` as a prop, so returning the key keeps assertions exact. // The component takes `t` as a prop, so returning the key keeps assertions exact.
@@ -181,4 +182,82 @@ describe('DayReorderPopup', () => {
expect(await screen.findByText('dayplan.daysOffline')).toBeInTheDocument() expect(await screen.findByText('dayplan.daysOffline')).toBeInTheDocument()
}) })
}) })
describe('adding a day', () => {
const tWithDate = (key: string, params?: Record<string, unknown>) => (params?.date ? `${key}|${params.date}` : key)
const controls = (overrides: Partial<DayAddControls> = {}): DayAddControls => ({
nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn(), ...overrides,
})
it('FE-PLANNER-DAYREORDER-017: a trip with dates offers a day without a date and the next date, by name', () => {
render(<DayReorderPopup {...makeProps({ days: threeDays(), t: tWithDate, dayAdd: controls() })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeEnabled()
expect(screen.getByRole('button', { name: /^dayplan\.addDatedDay\|.*Oct 13/ })).toBeEnabled()
expect(screen.queryByRole('button', { name: 'dayplan.addDay' })).not.toBeInTheDocument()
expect(screen.getByText(/^dayplan\.addDatedDayHint\|.*Oct 13/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.close' })).toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-018: each button adds its own kind of day', async () => {
const user = userEvent.setup()
const onAddDay = vi.fn()
const dayAdd = controls()
render(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd })} />)
await user.click(screen.getByRole('button', { name: 'dayplan.addUndatedDay' }))
expect(onAddDay).toHaveBeenCalledTimes(1)
expect(dayAdd.onAddDated).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'dayplan.addDatedDay' }))
expect(dayAdd.onAddDated).toHaveBeenCalledTimes(1)
expect(onAddDay).toHaveBeenCalledTimes(1)
})
it('FE-PLANNER-DAYREORDER-019: a trip without dates keeps the single button, which waits for the connection', async () => {
const user = userEvent.setup()
const onAddDay = vi.fn()
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd: controls({ nextDate: null }) })} />)
expect(screen.queryByRole('button', { name: 'dayplan.addDatedDay' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dayplan.addDay' }))
expect(onAddDay).toHaveBeenCalledTimes(1)
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd: controls({ nextDate: null, blocked: 'dayplan.daysOffline' }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addDay' })).toBeDisabled()
expect(screen.getByText('dayplan.daysOffline')).toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-020: busy or offline turns both off, a trip at the day limit only the dated one, and the line says why', () => {
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ busy: true }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ blocked: 'dayplan.daysOffline' }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
expect(screen.getByText('dayplan.daysOffline')).toBeInTheDocument()
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ datedBlocked: 'dashboard.tripTooLong' }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
expect(screen.getByText('dashboard.tripTooLong')).toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-021: the line under the buttons follows the pointer and the focus onto the undated one', async () => {
const user = userEvent.setup()
render(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls() })} />)
const undated = screen.getByRole('button', { name: 'dayplan.addUndatedDay' })
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
await user.hover(undated)
expect(screen.getByText('dayplan.addUndatedDayHint')).toBeInTheDocument()
await user.unhover(undated)
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
fireEvent.focus(undated)
expect(screen.getByText('dayplan.addUndatedDayHint')).toBeInTheDocument()
fireEvent.blur(undated)
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
// Both buttons are described by that line.
const hintId = screen.getByText('dayplan.addDatedDayHint').id
expect(undated).toHaveAttribute('aria-describedby', hintId)
})
})
}) })
@@ -1,10 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from 'lucide-react' import { GripVertical, ArrowUp, ArrowDown, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import Tooltip from '../shared/Tooltip' import Tooltip from '../shared/Tooltip'
import { DayAddFooter } from './DayAddFooter'
import { useNetworkMode } from '../../hooks/useNetworkMode' import { useNetworkMode } from '../../hooks/useNetworkMode'
import { dayLabel } from '../../utils/dayLabel' import { dayLabel } from '../../utils/dayLabel'
import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact' import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
import type { DayAddControls } from '../../utils/dayAdd'
import type { Day } from '../../types' import type { Day } from '../../types'
interface DayReorderPopupProps { interface DayReorderPopupProps {
@@ -14,6 +16,11 @@ interface DayReorderPopupProps {
locale: string locale: string
onReorder: (orderedIds: number[]) => void onReorder: (orderedIds: number[]) => void
onAddDay: () => void onAddDay: () => void
/**
* The planner's add controls. On a trip with dates they add a second button for
* the next calendar day; without them the footer keeps its single "Add day".
*/
dayAdd?: DayAddControls
/** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */ /** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */
onDeleteDay?: (dayId: number) => void onDeleteDay?: (dayId: number) => void
onClose: () => void onClose: () => void
@@ -21,12 +28,13 @@ interface DayReorderPopupProps {
/** /**
* Modal for moving whole days around: drag a row by its grip or use the up/down * Modal for moving whole days around: drag a row by its grip or use the up/down
* arrows, add a day at the end, or delete one. Day headers stay untouched, so * arrows, add a day, or delete one. Day headers stay untouched, so this is the
* this is the single surface for ordering. Reorders are applied optimistically * single surface for ordering. Reorders are applied optimistically by the store,
* by the store, so the list reflects each move immediately. A delete only asks: * so the list reflects each move immediately. A delete only asks: the planner
* the planner shows what goes with the day before anything happens. * shows what goes with the day before anything happens. Adding a day on a trip
* with dates offers both kinds, a day without a date or the next date.
*/ */
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onDeleteDay, onClose }: DayReorderPopupProps) { export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, dayAdd, onDeleteDay, onClose }: DayReorderPopupProps) {
const [dragIndex, setDragIndex] = useState<number | null>(null) const [dragIndex, setDragIndex] = useState<number | null>(null)
const [overIndex, setOverIndex] = useState<number | null>(null) const [overIndex, setOverIndex] = useState<number | null>(null)
const { offline } = useNetworkMode() const { offline } = useNetworkMode()
@@ -54,32 +62,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
onClose={onClose} onClose={onClose}
title={t('dayplan.reorderTitle')} title={t('dayplan.reorderTitle')}
size="md" size="md"
footer={ footer={<DayAddFooter dayAdd={dayAdd} onAddDay={onAddDay} onClose={onClose} t={t} locale={locale} />}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<button type="button"
onClick={onClose}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.close')}
</button>
<button type="button"
onClick={onAddDay}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Plus size={15} strokeWidth={2} />
{t('dayplan.addDay')}
</button>
</div>
}
> >
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}> <p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
{t('dayplan.reorderHint')} {t('dayplan.reorderHint')}
@@ -1,17 +1,71 @@
import { CalendarRange, Plus, Trash2 } from 'lucide-react' import { CalendarPlus, CalendarRange, Plus, Trash2 } from 'lucide-react'
import MSheet from '../../../components/MSheet' import MSheet from '../../../components/MSheet'
import { ReorderStack } from '../plan/MPlanTimelineRows' import { ReorderStack } from '../plan/MPlanTimelineRows'
import { INNER_CLS, TileHeader } from './MTripSheetUi' import { INNER_CLS, TileHeader } from './MTripSheetUi'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import { dayLabel } from '../../../../utils/dayLabel' import { dayLabel } from '../../../../utils/dayLabel'
import { formatDate } from '../../../../utils/formatters'
import type { DayAddControls } from '../../../../utils/dayAdd'
import type { MTripSheetsProps } from '../MTripShell' import type { MTripSheetsProps } from '../MTripShell'
const TILE = 'flex w-full items-center gap-[10px] rounded-[13px] px-[11px] text-left disabled:opacity-40'
const TILE_ICON = 'flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full'
const TILE_TITLE = 'block text-[0.8125rem] font-semibold leading-tight'
const TILE_HINT = 'mt-[2px] block text-[0.6875rem] leading-snug'
/** /**
* Day management sheet ('days'): move whole days up/down, append a new day or * The two ways to add a day on a trip with dates, as tiles: the next date on
* delete one, the mobile counterpart of the desktop DayReorderPopup and * top in the action colour, since it is the one that moves the trip's end, and
* button-based like the rest of the touch reordering (#1432). A day's places, * a day without a date below it in the dashed look the single button had.
* notes and bookings move with it (store handles that optimistically). Delete */
* only asks; the confirm sheet with what goes with the day is MTripSheets'. function DayAddTiles({ dayAdd, date, onAddUndated, t }: {
dayAdd: DayAddControls
date: string
onAddUndated: () => void
t: (key: string, params?: Record<string, string | number>) => string
}) {
const addOff = dayAdd.busy || !!dayAdd.blocked
return (
<div className="mt-[10px] flex flex-col gap-[6px]">
<button
type="button"
onClick={dayAdd.onAddDated}
disabled={addOff || !!dayAdd.datedBlocked}
className={`${TILE} bg-m-act py-[9px] text-m-actfg`}
>
<span className={`${TILE_ICON} bg-[color:color-mix(in_srgb,var(--m-actfg)_14%,transparent)]`}>
<CalendarPlus size={14} strokeWidth={2.2} />
</span>
<span className="min-w-0 flex-1">
<span className={TILE_TITLE}>{t('dayplan.addDatedDay', { date })}</span>
<span className={`${TILE_HINT} opacity-70`}>{dayAdd.datedBlocked ?? t('dayplan.addDatedDayHint', { date })}</span>
</span>
</button>
<button
type="button"
onClick={onAddUndated}
disabled={addOff}
className={`${TILE} border-[1.5px] border-dashed border-[color:var(--m-faint)] py-[8px] text-m-muted`}
>
<span className={`${TILE_ICON} bg-[color:var(--m-ic)]`}>
<Plus size={14} strokeWidth={2.2} />
</span>
<span className="min-w-0 flex-1">
<span className={`${TILE_TITLE} text-m-ink`}>{t('dayplan.addUndatedDay')}</span>
<span className={TILE_HINT}>{t('dayplan.addUndatedDayHint')}</span>
</span>
</button>
</div>
)
}
/**
* Day management sheet ('days'): move whole days up/down, add a 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'. On a trip with
* dates a day can be added with the next date as well as without one.
*/ */
export default function MDaysSheet({ planner, shell }: MTripSheetsProps) { export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
@@ -19,6 +73,9 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
const canEditDays = planner.can('day_edit', planner.trip) const canEditDays = planner.can('day_edit', planner.trip)
const ordered = [...planner.days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) const ordered = [...planner.days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
const deleteBlocked = planner.deleteDayBlocked const deleteBlocked = planner.deleteDayBlocked
const { dayAdd } = planner
// Offline, both say the same sentence; it is shown once.
const notices = [deleteBlocked, dayAdd.blocked].filter((n, i, all): n is string => !!n && all.indexOf(n) === i)
const move = (from: number, to: number) => { const move = (from: number, to: number) => {
if (to < 0 || to >= ordered.length || from === to) return if (to < 0 || to >= ordered.length || from === to) return
@@ -73,19 +130,28 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
</div> </div>
))} ))}
</div> </div>
{canEditDays && ( {canEditDays && dayAdd.nextDate && (
<DayAddTiles
dayAdd={dayAdd}
date={formatDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate}
onAddUndated={() => planner.handleAddDay()}
t={t}
/>
)}
{canEditDays && !dayAdd.nextDate && (
<button <button
type="button" type="button"
onClick={() => planner.handleAddDay()} onClick={() => planner.handleAddDay()}
className="mt-[10px] flex w-full items-center justify-center gap-[6px] rounded-[13px] border-[1.5px] border-dashed border-[color:var(--m-faint)] py-[9px] text-[0.75rem] font-semibold text-m-muted" disabled={dayAdd.busy || !!dayAdd.blocked}
className="mt-[10px] flex w-full items-center justify-center gap-[6px] rounded-[13px] border-[1.5px] border-dashed border-[color:var(--m-faint)] py-[9px] text-[0.75rem] font-semibold text-m-muted disabled:opacity-40"
> >
<Plus size={13} strokeWidth={2.2} /> <Plus size={13} strokeWidth={2.2} />
{t('dayplan.addDay')} {t('dayplan.addDay')}
</button> </button>
)} )}
{canEditDays && deleteBlocked && ( {canEditDays && notices.map(notice => (
<p className="mt-[10px] text-center text-[0.6875rem] text-m-muted">{deleteBlocked}</p> <p key={notice} className="mt-[10px] text-center text-[0.6875rem] text-m-muted">{notice}</p>
)} ))}
</div> </div>
</MSheet> </MSheet>
) )
+28
View File
@@ -1793,6 +1793,34 @@ describe('TripPlannerPage', () => {
}); });
}); });
describe('FE-PAGE-PLANNER-054: Adding the next date from the reorder dialog', () => {
it('hands the sidebar the planner add controls, whose dated add goes through the store', async () => {
vi.useFakeTimers();
seedTripStore({ id: 42 });
const appendDatedDay = vi.fn().mockResolvedValue({ id: 903, date: '2025-06-06' });
useTripStore.setState({
trip: buildTrip({ id: 42, start_date: '2025-06-01', end_date: '2025-06-05' }),
days: [buildDay({ id: 901, trip_id: 42, day_number: 1, date: '2025-06-01' })],
appendDatedDay,
});
renderPlannerPage(42);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
});
const { dayAdd } = capturedDayPlanSidebarProps.current;
expect(dayAdd).toMatchObject({ nextDate: '2025-06-06', blocked: null, datedBlocked: null, busy: false });
await act(async () => { dayAdd.onAddDated(); });
expect(appendDatedDay).toHaveBeenCalledWith(42);
await waitFor(() => {
expect(capturedDayPlanSidebarProps.current.dayAdd.busy).toBe(false);
});
});
});
describe('FE-PAGE-PLANNER-052: Road trip mode opens a booking the way the day plan does (#2428)', () => { 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 // 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. // road-trip hooks then read their own endpoints, answered empty here.
+3 -2
View File
@@ -304,7 +304,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay, route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick, handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory, handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle, handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, dayAdd, handleUpdateDayTitle,
deleteDayId, setDeleteDayId, deleteDayTitle, deleteDayLines, handleDeleteDay, confirmDeleteDay, deleteDayId, setDeleteDayId, deleteDayTitle, deleteDayLines, handleDeleteDay, confirmDeleteDay,
handleSaveReservation, handleSaveTransport, handleDeleteReservation, handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces, selectedPlace, dayOrderMap, dayPlaces,
@@ -658,6 +658,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onReorder={handleReorder} onReorder={handleReorder}
onReorderDays={handleReorderDays} onReorderDays={handleReorderDays}
onAddDay={handleAddDay} onAddDay={handleAddDay}
dayAdd={dayAdd}
onDeleteDay={handleDeleteDay} onDeleteDay={handleDeleteDay}
onUpdateDayTitle={handleUpdateDayTitle} onUpdateDayTitle={handleUpdateDayTitle}
onAssignToDay={handleAssignToDay} onAssignToDay={handleAssignToDay}
@@ -951,7 +952,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</div> </div>
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}> <div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
{mobileSidebarOpen === 'left' {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} 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 /> ? <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} dayAdd={dayAdd} 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 }} /> : <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> </div>
@@ -1298,7 +1298,9 @@ describe('TripPlannerPage — modals', () => {
it('FE-PAGE-TPW-062: the question before a booked night becomes a pause answers through the hook', () => { it('FE-PAGE-TPW-062: the question before a booked night becomes a pause answers through the hook', () => {
renderPage({ stayRelease: { stop: { stopType: null, dwellMinutes: 30 }, name: 'Hotel Fjord', booking: 'Booking 4711' } }) renderPage({ stayRelease: { stop: { stopType: null, dwellMinutes: 30 }, name: 'Hotel Fjord', booking: 'Booking 4711' } })
const [, , release] = confirmDialogs as unknown as Array<Record<string, () => unknown> & { isOpen: boolean }> // Found by its handler rather than its place: the delete-day question sits in front of it now.
const release = (confirmDialogs as unknown as Array<Record<string, () => unknown> & { isOpen: boolean }>)
.find(dialog => dialog.onConfirm === hookState.confirmStayRelease)!
expect(release.isOpen).toBe(true) expect(release.isOpen).toBe(true)
act(() => { release.onClose() }) act(() => { release.onClose() })
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { datedDayOption, type DayAddControls } from '../../utils/dayAdd'
import { formatDate } from '../../utils/formatters'
import type { Day, Trip } from '../../types'
type Translate = (key: string, params?: Record<string, string | number>) => string
interface DayAddOptions {
tripId: number
trip: Trip | null
days: Day[]
canEditDays: boolean
t: Translate
locale: string
toast: { success: (message: string) => unknown; error: (message: string) => unknown }
}
export interface DayAdd {
/** Add a day without a date at the end, or an empty day at a 1-based `position`. */
handleAddDay: (position?: number) => void
/** Everything the reorder dialog and the phone's day sheet need for their add buttons. */
dayAdd: DayAddControls
}
/**
* Adding a day, for both shells: the day without a date as before, and on a trip
* with dates the next calendar day, which extends the trip by one. The desktop
* dialog and the phone sheet only render what this returns, which keeps the logic
* in one place for the duplication gate.
*
* Both run one at a time. A second click while a day is on its way does nothing,
* or a double click on "Add with date" would grow the trip by two days. Both need
* a connection: the server places the new day and moves the ones behind it.
*/
export function useDayAdd(options: DayAddOptions): DayAdd {
const { tripId, trip, days, canEditDays, t, locale, toast } = options
const { offline } = useNetworkMode()
const [busy, setBusy] = useState(false)
// The state redraws the buttons; the ref stops a click that lands before that redraw.
const inFlight = useRef(false)
const { nextDate, datedBlocked } = useMemo(() => datedDayOption(trip, days, t), [trip, days, t])
const blocked = offline ? t('dayplan.daysOffline') : null
const runOnce = useCallback((add: () => Promise<void>) => {
if (inFlight.current) return
inFlight.current = true
setBusy(true)
add().finally(() => {
inFlight.current = false
setBusy(false)
})
}, [])
const handleAddDay = useCallback((position?: number) => {
if (!canEditDays || blocked) return
runOnce(() => useTripStore.getState().insertDay(tripId, position).then(
() => undefined,
(err: unknown) => { toast.error(err instanceof Error ? err.message : t('dayplan.addDayError')) },
))
}, [canEditDays, blocked, runOnce, tripId, toast, t])
const onAddDated = useCallback(() => {
if (!canEditDays || blocked || datedBlocked || !nextDate) return
runOnce(() => useTripStore.getState().appendDatedDay(tripId).then(
day => { toast.success(t('dayplan.tripExtended', { date: formatDate(day.date ?? nextDate, locale) ?? nextDate })) },
(err: unknown) => {
// The server's sentences are English, and the button already keeps a trip
// without dates or at the day limit from asking, so a refusal here is a
// failure the traveller only needs to hear about in their own language.
console.error('Failed to add a dated day:', err)
toast.error(t('dayplan.addDayError'))
},
))
}, [canEditDays, blocked, datedBlocked, nextDate, runOnce, tripId, toast, t, locale])
const dayAdd = useMemo<DayAddControls>(
() => ({ nextDate, blocked, datedBlocked, busy, onAddDated }),
[nextDate, blocked, datedBlocked, busy, onAddDated],
)
return { handleAddDay, dayAdd }
}
@@ -1,4 +1,4 @@
// FE-TP-HOOK-001 to FE-TP-HOOK-125 // FE-TP-HOOK-001 to FE-TP-HOOK-129
import React from 'react' import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react' import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext' import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -102,6 +102,7 @@ interface PlannerActions {
reorderAssignments: ReturnType<typeof vi.fn> reorderAssignments: ReturnType<typeof vi.fn>
reorderDays: ReturnType<typeof vi.fn> reorderDays: ReturnType<typeof vi.fn>
insertDay: ReturnType<typeof vi.fn> insertDay: ReturnType<typeof vi.fn>
appendDatedDay: ReturnType<typeof vi.fn>
deleteDay: ReturnType<typeof vi.fn> deleteDay: ReturnType<typeof vi.fn>
updateDayTitle: ReturnType<typeof vi.fn> updateDayTitle: ReturnType<typeof vi.fn>
addReservation: ReturnType<typeof vi.fn> addReservation: ReturnType<typeof vi.fn>
@@ -130,6 +131,7 @@ function makeActions(): PlannerActions {
reorderAssignments: vi.fn(async () => undefined), reorderAssignments: vi.fn(async () => undefined),
reorderDays: vi.fn(async () => undefined), reorderDays: vi.fn(async () => undefined),
insertDay: vi.fn(async () => undefined), insertDay: vi.fn(async () => undefined),
appendDatedDay: vi.fn(async () => ({ id: 99, date: '2026-06-04' })),
deleteDay: vi.fn(async () => undefined), deleteDay: vi.fn(async () => undefined),
updateDayTitle: vi.fn(async () => undefined), updateDayTitle: vi.fn(async () => undefined),
addReservation: vi.fn(async () => ({ id: 77 })), addReservation: vi.fn(async () => ({ id: 77 })),
@@ -2376,3 +2378,98 @@ describe('useTripPlanner: deleting a day', () => {
expect(actions.reorderDays).not.toHaveBeenCalled() expect(actions.reorderDays).not.toHaveBeenCalled()
}) })
}) })
describe('useTripPlanner: adding a day', () => {
// A trip running 1 to 3 June with one day per date and a spare day without one.
function seedDated(extra: Partial<TripStoreState> = {}) {
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' }),
buildDay({ id: 2, day_number: 2, date: '2026-06-02' }),
buildDay({ id: 3, day_number: 3, date: '2026-06-03' }),
buildDay({ id: 4, day_number: 4, date: null }),
],
...extra,
} as Partial<TripStoreState>)
}
it('FE-TP-HOOK-126: a trip with dates offers the day after its end date; one without dates offers none', async () => {
seedDated()
const dated = await renderPlanner()
expect(dated.result.current.dayAdd).toMatchObject({ nextDate: '2026-06-04', blocked: null, datedBlocked: null, busy: false })
dated.unmount()
seedTrip({ trip: buildTrip({ id: 42, start_date: null, end_date: null }), days: [buildDay({ id: 1, day_number: 1, date: null })] } as Partial<TripStoreState>)
const { result } = await renderPlanner()
expect(result.current.dayAdd.nextDate).toBeNull()
act(() => { result.current.dayAdd.onAddDated() })
expect(actions.appendDatedDay).not.toHaveBeenCalled()
})
it('FE-TP-HOOK-127: without day_edit neither kind of day is added', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 2, role: 'user' }) })
usePermissionsStore.setState({ permissions: { day_edit: 'admin' } })
seedDated()
const { result } = await renderPlanner()
act(() => { result.current.dayAdd.onAddDated() })
act(() => { result.current.handleAddDay() })
expect(actions.appendDatedDay).not.toHaveBeenCalled()
expect(actions.insertDay).not.toHaveBeenCalled()
})
it('FE-TP-HOOK-128: one day at a time, and the toast names the new end date', async () => {
seedDated()
let finish: (day: { id: number; date: string }) => void = () => {}
actions.appendDatedDay.mockImplementation(() => new Promise(resolve => { finish = resolve }))
const { result } = await renderPlanner()
act(() => { result.current.dayAdd.onAddDated() })
expect(result.current.dayAdd.busy).toBe(true)
// A double click, and a click on the other button while the first is on its way.
act(() => { result.current.dayAdd.onAddDated() })
act(() => { result.current.handleAddDay() })
expect(actions.appendDatedDay).toHaveBeenCalledTimes(1)
expect(actions.appendDatedDay).toHaveBeenCalledWith(42)
expect(actions.insertDay).not.toHaveBeenCalled()
await act(async () => { finish({ id: 99, date: '2026-06-04' }) })
await waitFor(() => expect(result.current.dayAdd.busy).toBe(false))
expect(toasts).toContainEqual(expect.objectContaining({ type: 'success', message: expect.stringMatching(/^Day added\. The trip now ends on .*Jun 4/) }))
// Free again: the next click goes through.
await act(async () => { result.current.handleAddDay() })
expect(actions.insertDay).toHaveBeenCalledWith(42, undefined)
await waitFor(() => expect(result.current.dayAdd.busy).toBe(false))
})
it('FE-TP-HOOK-129: a failure is said in the traveller language; offline and at the day limit nothing is asked', async () => {
seedDated()
actions.appendDatedDay.mockRejectedValueOnce(new Error('A trip can span at most 999 days'))
const failing = await renderPlanner()
await act(async () => { failing.result.current.dayAdd.onAddDated() })
await waitFor(() => expect(toasts).toContainEqual(expect.objectContaining({ type: 'error', message: 'Failed to add day' })))
await waitFor(() => expect(failing.result.current.dayAdd.busy).toBe(false))
failing.unmount()
actions.appendDatedDay.mockClear()
env.forcedOffline = true
seedDated()
const offline = await renderPlanner()
expect(offline.result.current.dayAdd.blocked).toBe('Changing days needs a connection')
act(() => { offline.result.current.dayAdd.onAddDated() })
act(() => { offline.result.current.handleAddDay() })
expect(actions.appendDatedDay).not.toHaveBeenCalled()
expect(actions.insertDay).not.toHaveBeenCalled()
offline.unmount()
env.forcedOffline = false
seedDated({ trip: buildTrip({ id: 42, start_date: '2026-01-01', end_date: '2028-09-25' }) } as Partial<TripStoreState>)
const { result } = await renderPlanner()
expect(result.current.dayAdd.datedBlocked).toBe('A trip can span at most 999 days')
act(() => { result.current.dayAdd.onAddDated() })
expect(actions.appendDatedDay).not.toHaveBeenCalled()
})
})
@@ -77,6 +77,7 @@ import {
} from '../../utils/connectionsVisibility' } from '../../utils/connectionsVisibility'
import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlaces' import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlaces'
import { useDayDelete } from './useDayDelete' import { useDayDelete } from './useDayDelete'
import { useDayAdd } from './useDayAdd'
/** Stable empty list so the road trip hook stays inert while its mode is off. */ /** Stable empty list so the road trip hook stays inert while its mode is off. */
const EMPTY_DAYS: Day[] = [] const EMPTY_DAYS: Day[] = []
@@ -2521,10 +2522,9 @@ export function useTripPlanner() {
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError'))) .catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError')))
}, [tripId, toast, pushUndo]) }, [tripId, toast, pushUndo])
const handleAddDay = useCallback((position?: number) => { const { handleAddDay, dayAdd } = useDayAdd({
tripActions.insertDay(tripId, position) tripId, trip, days, canEditDays: can('day_edit', trip), t, locale, toast,
.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 // A deleted day can take a stay along, and the selected day's route may have
// lost its day or its stops. // lost its day or its stops.
@@ -2861,7 +2861,7 @@ export function useTripPlanner() {
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay, route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handlePoiClick, handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handlePoiClick,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory, handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle, handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, dayAdd, handleUpdateDayTitle,
...dayDelete, ...dayDelete,
handleSaveReservation, handleSaveTransport, handleDeleteReservation, handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces, selectedPlace, dayOrderMap, dayPlaces,
+28 -1
View File
@@ -1,4 +1,4 @@
// FE-REPO-DAY-001 to FE-REPO-DAY-006 // FE-REPO-DAY-001 to FE-REPO-DAY-008
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import 'fake-indexeddb/auto' import 'fake-indexeddb/auto'
import { http, HttpResponse } from 'msw' import { http, HttpResponse } from 'msw'
@@ -88,3 +88,30 @@ describe('dayRepo.remove', () => {
expect(await offlineDb.days.get(51)).toBeDefined() expect(await offlineDb.days.get(51)).toBeDefined()
}) })
}) })
describe('dayRepo.appendDated', () => {
it('FE-REPO-DAY-007: online, asks for a dated day and caches the trip it answers with', async () => {
const trip = buildTrip({ id: 5, end_date: '2026-06-04' })
const day = buildDay({ id: 61, trip_id: 5, day_number: 4, date: '2026-06-04' })
let body: unknown = null
server.use(http.post('/api/trips/5/days', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ day, trip }, { status: 201 })
}))
const result = await dayRepo.appendDated(5)
expect(body).toEqual({ dated: true })
expect(result).toMatchObject({ day: { id: 61, date: '2026-06-04' }, trip: { end_date: '2026-06-04' } })
expect(await offlineDb.trips.get(5)).toMatchObject({ end_date: '2026-06-04' })
})
it('FE-REPO-DAY-008: offline, refuses without a request', async () => {
const hit = vi.fn()
server.use(http.post('/api/trips/5/days', () => { hit(); return HttpResponse.json({}) }))
setOnline(false)
await expect(dayRepo.appendDated(5)).rejects.toThrow('Adding a day needs a connection')
expect(hit).not.toHaveBeenCalled()
})
})
+15
View File
@@ -35,4 +35,19 @@ export const dayRepo = {
if (result.trip) await upsertTrip(result.trip) if (result.trip) await upsertTrip(result.trip)
return { trip: result.trip } return { trip: result.trip }
}, },
/**
* Add the calendar day after the trip's last date, which extends the trip by
* one day. Online only, like remove: the server picks the date and moves the
* days without one back, and a queued offline write replayed later could land
* on a date the trip has grown past by then. The trip the server answers with
* (new end date, new day count) replaces the cached one; the days are the
* caller's to refresh, since the ones behind the new day moved.
*/
async appendDated(tripId: number | string): Promise<{ day: Day; trip?: Trip }> {
if (isEffectivelyOffline()) throw new Error('Adding a day needs a connection')
const result = await daysApi.create(tripId, { dated: true })
if (result.trip) await upsertTrip(result.trip)
return { day: result.day, trip: result.trip }
},
} }
+49 -1
View File
@@ -1,4 +1,4 @@
// FE-TSLICE-DAYS-001 to FE-TSLICE-DAYS-011 (whole-day reorder + insert, #589; delete) // FE-TSLICE-DAYS-001 to FE-TSLICE-DAYS-013 (whole-day reorder + insert, #589; delete; dated append)
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server'; import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
@@ -248,4 +248,52 @@ describe('daysSlice', () => {
expect(useTripStore.getState().trip?.end_date).toBe('2025-06-03'); expect(useTripStore.getState().trip?.end_date).toBe('2025-06-03');
}); });
}); });
describe('appendDatedDay', () => {
it('FE-TSLICE-DAYS-012: asks for the next date, takes the grown trip and pulls the days, but not the bookings', async () => {
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-03' }),
days: datedDays(),
});
const created = buildDay({ id: 4, trip_id: 1, day_number: 4, date: '2025-06-04' });
const grown = buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-04', day_count: 4 });
let body: Record<string, unknown> = {};
let reservationsLoaded = false;
server.use(
http.post('/api/trips/1/days', async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ day: created, trip: grown }, { status: 201 });
}),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [...datedDays(), created] })),
http.get('/api/trips/1/reservations', () => {
reservationsLoaded = true;
return HttpResponse.json({ reservations: [] });
}),
);
const day = await useTripStore.getState().appendDatedDay(1);
expect(body).toEqual({ dated: true });
expect(day).toMatchObject({ id: 4, date: '2025-06-04' });
expect(useTripStore.getState().trip).toMatchObject({ end_date: '2025-06-04', day_count: 4 });
expect(useTripStore.getState().days.map(d => d.id)).toEqual([1, 2, 3, 4]);
expect(reservationsLoaded).toBe(false);
});
it('FE-TSLICE-DAYS-013: a refusal leaves the trip and the days as they were and throws the server sentence', async () => {
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-03' }),
days: datedDays(),
});
server.use(
http.post('/api/trips/1/days', () => HttpResponse.json({ error: 'A trip can span at most 999 days' }, { status: 400 })),
);
await expect(useTripStore.getState().appendDatedDay(1)).rejects.toThrow('A trip can span at most 999 days');
expect(useTripStore.getState().trip?.end_date).toBe('2025-06-03');
expect(useTripStore.getState().days.map(d => d.id)).toEqual([1, 2, 3]);
});
});
}); });
+16
View File
@@ -11,6 +11,7 @@ type GetState = StoreApi<TripStoreState>['getState']
export interface DaysSlice { export interface DaysSlice {
reorderDays: (tripId: number | string, orderedIds: number[]) => Promise<void> reorderDays: (tripId: number | string, orderedIds: number[]) => Promise<void>
insertDay: (tripId: number | string, position?: number) => Promise<Day | undefined> insertDay: (tripId: number | string, position?: number) => Promise<Day | undefined>
appendDatedDay: (tripId: number | string) => Promise<Day>
deleteDay: (tripId: number | string, dayId: number) => Promise<void> deleteDay: (tripId: number | string, dayId: number) => Promise<void>
} }
@@ -80,6 +81,21 @@ export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({
} }
}, },
// Add the calendar day after the trip's last date. The trip grows by that day,
// so the store takes the trip the server answered with (the socket echo skips
// this tab) and pulls the days, where the ones without a date moved back one
// place. No booking changes date, so reservations stay as they are.
appendDatedDay: async (tripId) => {
const outcome = await dayRepo.appendDated(tripId).then(
answer => answer,
(failure: unknown) => ({ failure }),
)
if ('failure' in outcome) throw new Error(getApiErrorMessage(outcome.failure, 'Error adding day'))
if (outcome.trip) set({ trip: outcome.trip })
await get().refreshDays(tripId)
return outcome.day
},
// Delete a day. The list closes up at once; a refusal (the last day, no // 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 // 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 // the re-dated days and the bookings, which were re-stamped or let go of, and
+34
View File
@@ -0,0 +1,34 @@
// FE-UTIL-DAYADD-001 to FE-UTIL-DAYADD-003
import { describe, it, expect } from 'vitest'
import { addIsoDays, MAX_TRIP_DAYS } from '@trek/shared'
import { datedDayOption } from './dayAdd'
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}|${Object.values(params).join('|')}` : key
describe('datedDayOption', () => {
it('FE-UTIL-DAYADD-001: offers the day after the end date, or after a day dated past it', () => {
const trip = { start_date: '2026-10-10', end_date: '2026-10-12' }
expect(datedDayOption(trip, [{ date: '2026-10-10' }, { date: '2026-10-12' }, { date: null }], t))
.toEqual({ nextDate: '2026-10-13', datedBlocked: null })
expect(datedDayOption(trip, [{ date: '2026-10-14' }], t))
.toEqual({ nextDate: '2026-10-15', datedBlocked: null })
})
it('FE-UTIL-DAYADD-002: a trip without dates has no dated day to offer', () => {
expect(datedDayOption({ start_date: null, end_date: null }, [{ date: null }], t))
.toEqual({ nextDate: null, datedBlocked: null })
expect(datedDayOption({ start_date: '2026-10-10', end_date: null }, [], t))
.toEqual({ nextDate: null, datedBlocked: null })
expect(datedDayOption(null, [], t)).toEqual({ nextDate: null, datedBlocked: null })
})
it('FE-UTIL-DAYADD-003: a trip at the day limit names its date but says why it cannot grow', () => {
const start = '2026-01-01'
const end = addIsoDays(start, MAX_TRIP_DAYS - 1)
expect(datedDayOption({ start_date: start, end_date: end }, [], t)).toEqual({
nextDate: addIsoDays(end, 1),
datedBlocked: `dashboard.tripTooLong|${MAX_TRIP_DAYS}`,
})
})
})
+37
View File
@@ -0,0 +1,37 @@
import { MAX_TRIP_DAYS, planDatedAppend } from '@trek/shared'
import type { Day, Trip } from '../types'
type Translate = (key: string, params?: Record<string, string | number>) => string
/**
* What the reorder dialog and the phone's day sheet need to offer adding a day:
* one button for a day without a date, and on a trip with dates a second one for
* the next calendar day. The planner computes it once for both shells.
*/
export interface DayAddControls {
/** The date "Add with date" gives the new day (ISO), or null on a trip without dates, which keeps the single button. */
nextDate: string | null
/** Why no day can be added right now (no connection), or null. */
blocked: string | null
/** Why the dated day in particular cannot be added (the trip is at the day limit), or null. */
datedBlocked: string | null
/** A day is on its way; both buttons wait for it, so a double click adds one day, not two. */
busy: boolean
onAddDated: () => void
}
/**
* The dated half of adding a day: the date the new day would get, and the reason
* it cannot be added when the trip is already at the day limit. The rule is the
* server's own (planDatedAppend), so the button never promises a date the server
* would not give, and never offers a day the server would refuse.
*/
export function datedDayOption(
trip: Pick<Trip, 'start_date' | 'end_date'> | null | undefined,
days: readonly Pick<Day, 'date'>[],
t: Translate,
): Pick<DayAddControls, 'nextDate' | 'datedBlocked'> {
const plan = trip ? planDatedAppend(trip, days.map(d => d.date)) : null
if (!plan) return { nextDate: null, datedBlocked: null }
return { nextDate: plan.date, datedBlocked: plan.fits ? null : t('dashboard.tripTooLong', { days: MAX_TRIP_DAYS }) }
}
+4 -1
View File
@@ -16,7 +16,7 @@ import { buildTrip } from './factories';
export function buildTripActions(): Record<string, ReturnType<typeof vi.fn>> { export function buildTripActions(): Record<string, ReturnType<typeof vi.fn>> {
const names = [ const names = [
'addCategory', 'addDayNote', 'addFile', 'addPackingContributor', 'addPackingItem', 'addCategory', 'addDayNote', 'addFile', 'addPackingContributor', 'addPackingItem',
'addPlace', 'addReservation', 'addTodoItem', 'assignPlaceToDay', 'clonePackingItem', 'addPlace', 'addReservation', 'addTodoItem', 'appendDatedDay', 'assignPlaceToDay', 'clonePackingItem',
'deleteBudgetItem', 'deleteDayNote', 'deleteFile', 'deletePackingItem', 'deletePlace', 'deleteBudgetItem', 'deleteDayNote', 'deleteFile', 'deletePackingItem', 'deletePlace',
'deleteDay', 'deletePlacesMany', 'deleteReservation', 'deleteTodoItem', 'insertDay', 'loadBudgetItems', 'deleteDay', 'deletePlacesMany', 'deleteReservation', 'deleteTodoItem', 'insertDay', 'loadBudgetItems',
'loadFiles', 'loadReservations', 'loadTrip', 'moveAssignment', 'moveDayNote', 'ratePlace', 'loadFiles', 'loadReservations', 'loadTrip', 'moveAssignment', 'moveDayNote', 'ratePlace',
@@ -185,6 +185,9 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
deletePlaceIds: null, deletePlaceIds: null,
setDeletePlaceIds: vi.fn(), setDeletePlaceIds: vi.fn(),
// Adding a day from the days sheet: a trip without dates, so the single button.
dayAdd: { nextDate: null, blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn() },
// Deleting a day from the days sheet: no question open, nothing blocking. // Deleting a day from the days sheet: no question open, nothing blocking.
deleteDayId: null, deleteDayId: null,
setDeleteDayId: vi.fn(), setDeleteDayId: vi.fn(),
@@ -4,9 +4,9 @@ import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/
import type { Day } from '../../../../src/types' import type { Day } from '../../../../src/types'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store' import { resetAllStores } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render' import { cleanup, fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-DAYSS-001 to FE-MOB-DAYSS-015 // FE-MOB-DAYSS-001 to FE-MOB-DAYSS-019
// //
// The sheet reads its copy from the real TranslationProvider (useTranslation), // The sheet reads its copy from the real TranslationProvider (useTranslation),
// not from planner.t — assertions therefore go against the English strings. // not from planner.t — assertions therefore go against the English strings.
@@ -143,4 +143,50 @@ describe('MDaysSheet', () => {
expect(planner.handleDeleteDay).not.toHaveBeenCalled() expect(planner.handleDeleteDay).not.toHaveBeenCalled()
expect(screen.getByText('A trip needs at least one day')).toBeInTheDocument() expect(screen.getByText('A trip needs at least one day')).toBeInTheDocument()
}) })
describe('adding a day on a trip with dates', () => {
const dayAdd = (overrides: Partial<TripPlanner['dayAdd']> = {}): TripPlanner['dayAdd'] => ({
nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn(), ...overrides,
})
const datedTile = () => screen.getByRole('button', { name: /^Add .*Oct 13/ })
const undatedTile = () => screen.getByRole('button', { name: /^Add day without date/ })
it('FE-MOB-DAYSS-016: two tiles, the next date and a day without one, each with what it does', () => {
const { planner } = renderSheet({ dayAdd: dayAdd() })
expect(datedTile()).toHaveTextContent(/Extends the trip by one day, until .*Oct 13\./)
expect(undatedTile()).toHaveTextContent('The trip dates stay as they are.')
expect(screen.queryByRole('button', { name: 'Add day' })).not.toBeInTheDocument()
fireEvent.click(datedTile())
expect(planner.dayAdd.onAddDated).toHaveBeenCalledTimes(1)
expect(planner.handleAddDay).not.toHaveBeenCalled()
fireEvent.click(undatedTile())
expect(planner.handleAddDay).toHaveBeenCalledTimes(1)
})
it('FE-MOB-DAYSS-017: a read-only member gets neither tile', () => {
renderSheet({ can: vi.fn(() => false), dayAdd: dayAdd() })
expect(screen.queryByRole('button', { name: /^Add/ })).not.toBeInTheDocument()
})
it('FE-MOB-DAYSS-018: busy or offline turns both off, and the offline sentence is said once', () => {
renderSheet({ dayAdd: dayAdd({ busy: true }) })
expect(datedTile()).toBeDisabled()
expect(undatedTile()).toBeDisabled()
cleanup()
const offline = 'Changing days needs a connection'
renderSheet({ deleteDayBlocked: offline, dayAdd: dayAdd({ blocked: offline }) })
expect(datedTile()).toBeDisabled()
expect(undatedTile()).toBeDisabled()
expect(screen.getAllByText(offline)).toHaveLength(1)
})
it('FE-MOB-DAYSS-019: a trip at the day limit keeps the undated tile and says why the dated one is off', () => {
renderSheet({ dayAdd: dayAdd({ datedBlocked: 'A trip can span at most 999 days' }) })
expect(datedTile()).toBeDisabled()
expect(datedTile()).toHaveTextContent('A trip can span at most 999 days')
expect(undatedTile()).toBeEnabled()
})
})
}) })
@@ -72,5 +72,16 @@ describe('remoteEventHandler > trip', () => {
expect(refreshDays).not.toHaveBeenCalled(); expect(refreshDays).not.toHaveBeenCalled();
expect(loadReservations).not.toHaveBeenCalled(); expect(loadReservations).not.toHaveBeenCalled();
}); });
it('FE-WSEVT-TRIP-006: a trip grown by a dated day (end date alone) refetches days + reservations', () => {
useTripStore.setState({ trip: buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-05' }) });
const { refreshDays, loadReservations, dispatchSpy } = stubRefresh();
const grown = buildTrip({ id: 1, start_date: '2025-06-01', end_date: '2025-06-06', day_count: 6 });
useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: grown });
expect(useTripStore.getState().trip).toMatchObject({ end_date: '2025-06-06', day_count: 6 });
expect(refreshDays).toHaveBeenCalledWith(1);
expect(loadReservations).toHaveBeenCalledWith(1);
expect(firedAccommodationsRefresh(dispatchSpy)).toBe(true);
});
}); });
}); });
+36 -8
View File
@@ -11,7 +11,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { User } from '../../types'; import type { User } from '../../types';
import { DaysService, DayReorderError } from './days.service'; import { DaysService, DayReorderError, DayAppendError, type DatedDayAppend } from './days.service';
import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service'; import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service';
import { DayCreateDto, DayReorderDto, DayTransportDto, DayUpdateDto } from './days.dto'; import { DayCreateDto, DayReorderDto, DayTransportDto, DayUpdateDto } from './days.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -48,18 +48,46 @@ export class DaysController {
@Body() body: DayCreateDto, @Body() body: DayCreateDto,
@Headers('x-socket-id') socketId?: string, @Headers('x-socket-id') socketId?: string,
) { ) {
if (body.dated) return this.appendDated(tripId, user, body.notes, socketId);
// A `position` means "insert a new empty day here" (which on a dated trip // A `position` means "insert a new empty day here" (which on a dated trip
// extends the trip and re-pins dates); without it, the legacy append. // extends the trip and re-pins dates); without it, the legacy append.
const day = body.position !== undefined if (body.position !== undefined) {
? this.days.insert(tripId, body.position) const day = this.days.insert(tripId, body.position);
: this.days.create(tripId, body.date, body.notes); // An insert can shuffle dates/positions of other days, so collaborators
// An insert can shuffle dates/positions of other days, so collaborators // refetch the whole list. It also grew the trip by a day, and on a dated
// refetch the whole list; a plain append only needs the new day. // trip moved its end date, which the trip header of every copy reads.
const event = body.position !== undefined ? 'day:reordered' : 'day:created'; this.days.broadcast(tripId, 'day:reordered', { day }, socketId);
this.days.broadcast(tripId, event, { day }, socketId); const trip = this.days.getTripForViewer(tripId, user.id);
if (trip) this.days.broadcast(tripId, 'trip:updated', { trip }, socketId);
return { day, trip };
}
// A plain append only needs the new day.
const day = this.days.create(tripId, body.date, body.notes);
this.days.broadcast(tripId, 'day:created', { day }, socketId);
return { day }; return { day };
} }
/**
* The calendar day after the trip's last date, which extends the trip by one
* day. `trip` in the answer is additive: the socket echo skips this tab, and
* the trip header shows the new end date.
*/
private appendDated(tripId: string, user: User, notes: string | undefined, socketId: string | undefined) {
let append: DatedDayAppend;
try {
append = this.days.appendDated(tripId, user.id, notes);
} catch (err) {
// A trip without dates, or one at the day limit; nothing was written.
if (err instanceof DayAppendError) throw new HttpException({ error: err.message }, 400);
throw err;
}
this.days.announceDatedAppend(append, {
all: (event, payload) => this.days.broadcast(tripId, event, payload, undefined),
others: (event, payload) => this.days.broadcast(tripId, event, payload, socketId),
});
return { day: append.day, trip: append.trip };
}
@RequirePermission('day_edit') @RequirePermission('day_edit')
@Put('reorder') @Put('reorder')
reorder( reorder(
+31 -4
View File
@@ -8,10 +8,10 @@ import { z } from 'zod';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { import {
dayCreateRequestSchema, dayReorderRequestSchema, dayUpdateRequestSchema, DAY_CREATE_DATED_CONFLICT, dayCreateRequestSchema, dayReorderRequestSchema, dayUpdateRequestSchema,
} from '@trek/shared'; } from '@trek/shared';
import type { DayCreateRequest, DayReorderRequest, DayUpdateRequest } from '@trek/shared'; import type { DayCreateRequest, DayReorderRequest, DayUpdateRequest } from '@trek/shared';
import { DaysService, DayReorderError } from './days.service'; import { DaysService, DayReorderError, DayAppendError, type DatedDayAppend, type DaySender } from './days.service';
import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service'; import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service';
import type { MirrorSender } from '../accommodations/accommodations.service'; import type { MirrorSender } from '../accommodations/accommodations.service';
@@ -80,23 +80,25 @@ export class DaysMcp {
@Tool({ @Tool({
name: 'create_day', name: 'create_day',
description: 'Add a day to a trip. Without `position` the day is appended at the end, optionally with a date and notes. With `position` an empty day is slotted in at that place instead, which is the way to add a day in the middle of an itinerary that already has days.', description: 'Add a day to a trip. Without `position` the day is appended at the end, optionally with a date and notes. With `position` an empty day is slotted in at that place instead, which is the way to add a day in the middle of an itinerary that already has days. With `dated` the trip grows by the calendar day after its last date: the new day gets that date and goes right behind the last dated day, and the end date of the trip moves to it, while `position` re-dates the days after the slot it fills.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
date: dayCreateRequestSchema.shape.date.describe('ISO date string YYYY-MM-DD, optional for dateless trips'), date: dayCreateRequestSchema.shape.date.describe('ISO date string YYYY-MM-DD, optional for dateless trips'),
notes: dayCreateRequestSchema.shape.notes, notes: dayCreateRequestSchema.shape.notes,
position: dayCreateRequestSchema.shape.position.describe('1-based slot to insert an empty day at; omit to append at the end. On a dated trip the days keep their calendar slots, so the trip gains one day at its end and bookings move with the day they sit on. date and notes are ignored when this is set, as on the REST route.'), position: dayCreateRequestSchema.shape.position.describe('1-based slot to insert an empty day at; omit to append at the end. On a dated trip the days keep their calendar slots, so the trip gains one day at its end and bookings move with the day they sit on. date and notes are ignored when this is set, as on the REST route.'),
dated: dayCreateRequestSchema.shape.dated.describe('Append the calendar day after the last date of the trip and extend the trip by one day. Days without a date stay undated and move one place back. Cannot be combined with position or date. Only for trips with dates.'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' }, access: { group: 'trips', mode: 'write' },
}) })
async createDay( async createDay(
{ tripId, date, notes, position }: { tripId: number } & DayCreateRequest, { tripId, date, notes, position, dated }: { tripId: number } & DayCreateRequest,
ctx: McpContext, ctx: McpContext,
) { ) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (dated) return this.appendDated(tripId, notes, date, position, ctx);
if (position === undefined) { if (position === undefined) {
const day = this.days.create(tripId, date, notes); const day = this.days.create(tripId, date, notes);
this.guards.safeBroadcast(tripId, 'day:created', { day }); this.guards.safeBroadcast(tripId, 'day:created', { day });
@@ -107,6 +109,9 @@ export class DaysMcp {
// An insert renumbers and re-dates every later day, so collaborators get // An insert renumbers and re-dates every later day, so collaborators get
// the list-wide event and refetch, the same one the REST create route sends. // the list-wide event and refetch, the same one the REST create route sends.
this.guards.safeBroadcast(tripId, 'day:reordered', { day }); this.guards.safeBroadcast(tripId, 'day:reordered', { day });
// The trip grew by a day, and on a dated trip its end date moved.
const trip = this.days.getTripForViewer(tripId, ctx.userId);
if (trip) this.guards.safeBroadcast(tripId, 'trip:updated', { trip });
return ok({ day }); return ok({ day });
} catch (err) { } catch (err) {
// REST lets this bubble into a 500; a tool caller can act on the sentence. // REST lets this bubble into a 500; a tool caller can act on the sentence.
@@ -115,6 +120,28 @@ export class DaysMcp {
} }
} }
/**
* create_day with `dated`, the same as the REST route. The tool builds its input
* from the contract's fields alone, so the refusal the contract's refine gives
* for `dated` next to `date` or `position` is repeated here, word for word.
*/
private appendDated(
tripId: number, notes: string | undefined, date: string | undefined, position: number | undefined, ctx: McpContext,
) {
if (date !== undefined || position !== undefined) return errorResult(`${DAY_CREATE_DATED_CONFLICT}.`);
let append: DatedDayAppend;
try {
append = this.days.appendDated(tripId, ctx.userId, notes);
} catch (err) {
if (err instanceof DayAppendError) return errorResult(err.message);
throw err;
}
// A tool has no socket of its own, so every screen hears all of it.
const send: DaySender = (event, payload) => this.guards.safeBroadcast(tripId, event, payload as Record<string, unknown>);
this.days.announceDatedAppend(append, { all: send, others: send });
return ok({ day: append.day, trip: append.trip });
}
@Tool({ @Tool({
name: 'reorder_days', name: 'reorder_days',
description: 'Reorder the days of a trip by listing every one of its day IDs in the desired order. This moves whole days of the itinerary; to move places around inside a single day use reorder_day_assignments instead. Each day keeps its places, notes, stays and bookings, and on a dated trip the calendar dates stay pinned to their slots, so the content moves across the dates.', description: 'Reorder the days of a trip by listing every one of its day IDs in the desired order. This moves whole days of the itinerary; to move places around inside a single day use reorder_day_assignments instead. Each day keeps its places, notes, stays and bookings, and on a dated trip the calendar dates stay pinned to their slots, so the content moves across the dates.',
+17 -2
View File
@@ -5,7 +5,7 @@ import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors';
import { num, schemaMessage } from '../plugins/host/rpc-params'; import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types'; import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service'; import { RealtimeService } from '../realtime/realtime.service';
import { DaysService } from './days.service'; import { DaysService, DayAppendError, type DatedDayAppend, type DaySender } from './days.service';
import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service'; import { DayRemovalService, DayDeleteError, type DayRemoval } from './day-removal.service';
import type { MirrorSender } from '../accommodations/accommodations.service'; import type { MirrorSender } from '../accommodations/accommodations.service';
@@ -33,12 +33,27 @@ export class DaysRpc {
const parsed = dayCreateRequestSchema.safeParse(params.input); const parsed = dayCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`); if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION); this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
const input = parsed.data as { date?: string; notes?: string }; const input = parsed.data;
if (input.dated) return this.appendDated(tripId, actor, input.notes);
const day = this.days.create(tripId, input.date, input.notes); const day = this.days.create(tripId, input.date, input.notes);
this.realtime.broadcast(tripId, 'day:created', { day }); this.realtime.broadcast(tripId, 'day:created', { day });
return day; return day;
} }
/** days.create with `dated`: the calendar day after the trip's last date, as on REST and MCP. */
private appendDated(tripId: number, actor: number, notes: string | undefined): unknown {
let append: DatedDayAppend;
try {
append = this.days.appendDated(tripId, actor, notes);
} catch (err) {
if (err instanceof DayAppendError) throw new BadParams(err.message);
throw err;
}
const send: DaySender = (event, payload) => this.realtime.broadcast(tripId, event, payload);
this.days.announceDatedAppend(append, { all: send, others: send });
return append.day;
}
@PluginMethod('days.update', { permission: 'db:write:days' }) @PluginMethod('days.update', { permission: 'db:write:days' })
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown { update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId'); const tripId = num(params.tripId, 'tripId');
+107 -15
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { addIsoDays, MAX_TRIP_DAYS, planDatedAppend } from '@trek/shared';
import type { RoadtripDayBoundary, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service'; import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService, type TripAccess } from '../database/database.service'; import { DatabaseService, type TripAccess } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service'; import { PermissionsService } from '../permissions/permissions.service';
@@ -14,21 +15,10 @@ type Trip = TripAccess;
const MS_PER_DAY = 24 * 60 * 60 * 1000; const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** /**
* Add `n` days to a YYYY-MM-DD date string, staying entirely in UTC. * Add `n` days to a YYYY-MM-DD date string, staying entirely in UTC. The shared
* * day-grid rule, so the planner counts the date it promises the way this writes it.
* Deliberately never builds a local-time Date: `new Date('2026-06-07T00:00:00')`
* parses as *server-local* midnight, so a later .toISOString() round-trips through
* UTC and lands on the previous day whenever the server sits east of Greenwich.
*/ */
export function addDays(date: string, n: number): string { export const addDays = addIsoDays;
const [y, m, d] = date.split('-').map(Number);
const t = Date.UTC(y, m - 1, d) + n * MS_PER_DAY;
const dt = new Date(t);
const yyyy = dt.getUTCFullYear();
const mm = String(dt.getUTCMonth() + 1).padStart(2, '0');
const dd = String(dt.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function dayDelta(from: string, to: string): number { function dayDelta(from: string, to: string): number {
const [fy, fm, fd] = from.split('-').map(Number); const [fy, fm, fd] = from.split('-').map(Number);
@@ -44,6 +34,31 @@ function withDatePart(timestamp: string, date: string): string {
/** Thrown for invalid reorder/insert requests; mapped to HTTP 400 by the controller. */ /** Thrown for invalid reorder/insert requests; mapped to HTTP 400 by the controller. */
export class DayReorderError extends Error {} export class DayReorderError extends Error {}
/**
* A dated day the trip cannot take: it has no dates, or it would grow past the
* day limit. REST answers 400, MCP a tool error, a plugin BadParams.
*/
export class DayAppendError extends Error {}
export const NO_DATES_MESSAGE = 'This trip has no dates. Add a day without a date instead.';
/** How a surface sends what a day write changed; the MCP tools and the plugin RPC hand the same sender twice. */
export type DaySender = <E extends TrekWsTripEventName>(event: E, payload: TrekWsPayload<E>) => void;
/** What adding the next dated day did to the trip, for the surface that announces it. */
export interface DatedDayAppend {
/** The new day, in the shape the create route answers with. */
day: Day & { assignments: unknown[]; notes_items: unknown[] };
/** The trip's end date now, which is the new day's date. */
endDate: string;
/** The road trip day boundaries after they moved back with their days, or null when none moved. */
boundaries: RoadtripDayBoundary[] | null;
/** The trip re-read in list shape for the viewer: its end date and its day count changed. */
trip: unknown;
}
type DayRow = { id: number; day_number: number; date: string | null };
/** /**
* Day domain service — owns the day + accommodation SQL (moved 1:1 from the * Day domain service — owns the day + accommodation SQL (moved 1:1 from the
* legacy services/dayService.ts: identical statements, the `||` falsy-coercion * legacy services/dayService.ts: identical statements, the `||` falsy-coercion
@@ -522,4 +537,81 @@ export class DaysService {
return { ...day, assignments: [], notes_items: [] }; return { ...day, assignments: [], notes_items: [] };
} }
/**
* Add the calendar day after the trip's last date and extend the trip to it.
*
* Unlike insert(), no day that is already there changes its date. The new day
* goes right behind the last dated day, so the days without a date stay
* undated and move one place back, the road trip boundaries drawn on them with
* them. No booking moves either. One transaction, reads included, so two clicks
* in a row add two days one after the other instead of the same date twice.
*
* Throws a DayAppendError for a trip without dates and for one that would grow
* past the day limit; nothing is written then.
*/
appendDated(tripId: string | number, viewerId: number, notes?: string): DatedDayAppend {
const trip = Number(tripId);
const appended = this.db.transaction(() => {
const range = this.db.get<{ start_date: string | null; end_date: string | null }>(
'SELECT start_date, end_date FROM trips WHERE id = ?', trip,
);
const rows = this.db.all<DayRow>('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number', trip);
const plan = planDatedAppend(range ?? {}, rows.map(r => r.date));
if (!plan) throw new DayAppendError(NO_DATES_MESSAGE);
if (!plan.fits) throw new DayAppendError(`A trip can span at most ${MAX_TRIP_DAYS} days`);
// The dates are a prefix of the day order on any trip the planner wrote;
// the highest dated number keeps that true even on one that drifted.
const position = rows.reduce((last, r) => (r.date ? Math.max(last, r.day_number) : last), 0) + 1;
const later = rows.filter(r => r.day_number >= position);
const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
// Two phases, to get past UNIQUE(trip_id, day_number) on the way.
later.forEach(r => setDayNumber.run(-r.day_number, r.id));
const inserted = this.db.run(
'INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)', trip, position, plan.date, notes || null,
);
later.forEach(r => setDayNumber.run(r.day_number + 1, r.id));
const boundaries = this.shiftBoundariesBack(trip, position);
this.db.run('UPDATE trips SET end_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', plan.date, trip);
const day = this.db.get<Day>('SELECT * FROM days WHERE id = ?', inserted.lastInsertRowid)!;
return { day: { ...day, assignments: [], notes_items: [] }, endDate: plan.date, boundaries };
});
return { ...appended, trip: this.getTripForViewer(trip, viewerId) };
}
/**
* Boundaries are keyed by day number, so the ones on the days that moved back
* move back with them. One at a time and from the last one down: 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. Returns the list after the move, or null
* when no boundary had to move.
*/
private shiftBoundariesBack(tripId: number, fromNumber: number): RoadtripDayBoundary[] | null {
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 DESC',
tripId, fromNumber,
);
if (later.length === 0) return null;
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);
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,
);
}
/**
* The one place a dated append is fanned out. The collaborators take the new
* day through day:reordered, the insert shape, because it can land in front of
* the days without a date and day:created only appends; that event makes them
* pull the whole list. The trip follows for its end date and day count. Moved
* boundaries go to every screen, the adding one included, which holds them
* outside the day list.
*/
announceDatedAppend(append: DatedDayAppend, send: { all: DaySender; others: DaySender }): void {
send.others('day:reordered', { day: append.day });
if (append.boundaries) send.all('roadtripBoundary:changed', { boundaries: append.boundaries });
if (append.trip) send.others('trip:updated', { trip: append.trip });
}
} }
+25
View File
@@ -150,6 +150,31 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', (
expect(miss.body).toEqual({ error: 'Trip not found' }); expect(miss.body).toEqual({ error: 'Trip not found' });
}); });
it('201 create dated day extends the trip, and the days without a date move back', async () => {
db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (10, 1, ?, ?, ?)').run('Grow', '2026-09-01', '2026-09-02');
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (10, 1, ?)').run('2026-09-01');
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (10, 2, ?)').run('2026-09-02');
const spare = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (10, 3)').run().lastInsertRowid);
const res = await request(server).post('/api/trips/10/days').set('Cookie', sessionCookie(1)).send({ dated: true });
expect(res.status).toBe(201);
expect(res.body.day).toMatchObject({ trip_id: 10, day_number: 3, date: '2026-09-03', assignments: [], notes_items: [] });
expect(res.body.trip).toMatchObject({ id: 10, end_date: '2026-09-03', day_count: 4, is_owner: 1 });
expect(db.prepare('SELECT end_date FROM trips WHERE id = 10').get()).toEqual({ end_date: '2026-09-03' });
expect(db.prepare('SELECT day_number, date FROM days WHERE id = ?').get(spare)).toEqual({ day_number: 4, date: null });
});
it('400 dated with position, 400 dated on a trip without dates', async () => {
db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (11, 1, ?, ?, ?)').run('Mixed', '2026-09-01', '2026-09-01');
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (11, 1, ?)').run('2026-09-01');
const mixed = await request(server).post('/api/trips/11/days').set('Cookie', sessionCookie(1)).send({ dated: true, position: 1 });
expect(mixed.status).toBe(400);
expect(db.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = 11').get()).toEqual({ n: 1 });
const undated = await request(server).post('/api/trips/5/days').set('Cookie', sessionCookie(1)).send({ dated: true });
expect(undated.status).toBe(400);
expect(undated.body).toEqual({ error: 'This trip has no dates. Add a day without a date instead.' });
expect(db.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = 5').get()).toEqual({ n: 1 });
});
it('200 update day notes/title, 404 Day not found, 403 without permission', async () => { it('200 update day notes/title, 404 Day not found, 403 without permission', async () => {
const res = await request(server).put('/api/trips/5/days/3').set('Cookie', sessionCookie(1)) const res = await request(server).put('/api/trips/5/days/3').set('Cookie', sessionCookie(1))
.send({ notes: 'Walking day', title: 'Arrival' }); .send({ notes: 'Walking day', title: 'Arrival' });
+57 -2
View File
@@ -1,6 +1,6 @@
/** /**
* Days & Accommodations API integration tests. * Days & Accommodations API integration tests.
* Covers DAY-001 through DAY-010 and ACCOM-001 through ACCOM-006. * Covers DAY-001 through DAY-013 and ACCOM-001 through ACCOM-006.
*/ */
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest'; import request from 'supertest';
@@ -122,7 +122,7 @@ describe('List days', () => {
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Create day (DAY-006) // Create day (DAY-006, DAY-011 to DAY-013)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
describe('Create day', () => { describe('Create day', () => {
@@ -167,6 +167,61 @@ describe('Create day', () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
it('DAY-011: a dated create adds the next date behind the dated days, and the trip ends on it', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-08-01', end_date: '2026-08-02' });
const spare = createDay(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id))
.send({ dated: true });
expect(res.status).toBe(201);
expect(res.body.day).toMatchObject({ trip_id: trip.id, day_number: 3, date: '2026-08-03' });
expect(res.body.trip).toMatchObject({ id: trip.id, end_date: '2026-08-03', day_count: 4 });
expect(res.body.trip).not.toHaveProperty('feed_token', expect.anything());
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-03' });
const list = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
expect(list.body.days.map((d: { id: number; date: string | null }) => [d.id, d.date])).toEqual([
[expect.any(Number), '2026-08-01'],
[expect.any(Number), '2026-08-02'],
[res.body.day.id, '2026-08-03'],
[spare.id, null],
]);
});
it('DAY-012: a dated create on a trip without dates is refused with 400 and adds nothing', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Open Trip' });
createDay(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id))
.send({ dated: true });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'This trip has no dates. Add a day without a date instead.' });
expect(testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ n: 1 });
});
it('DAY-013: dated next to a position is refused by the contract with 400 and adds nothing', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-08-01', end_date: '2026-08-02' });
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id))
.send({ dated: true, position: 1 });
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toContain('dated cannot be combined with date or position');
expect(testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ n: 2 });
expect(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ end_date: '2026-08-02' });
});
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -14,7 +14,7 @@ import { DaysRpc } from '../../../src/nest/days/days.rpc';
import { DaysModule } from '../../../src/nest/days/days.module'; import { DaysModule } from '../../../src/nest/days/days.module';
import { ItineraryRpc } from '../../../src/nest/assignments/itinerary.rpc'; import { ItineraryRpc } from '../../../src/nest/assignments/itinerary.rpc';
import { AssignmentsModule } from '../../../src/nest/assignments/assignments.module'; import { AssignmentsModule } from '../../../src/nest/assignments/assignments.module';
import type { DaysService } from '../../../src/nest/days/days.service'; import { DayAppendError, NO_DATES_MESSAGE, type DatedDayAppend, type DaysService } from '../../../src/nest/days/days.service';
import { DayDeleteError, type DayRemoval, type DayRemovalService } from '../../../src/nest/days/day-removal.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 { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
@@ -32,6 +32,15 @@ function build(opts: { canEdit?: boolean } = {}) {
create: vi.fn((tripId: number, date?: string) => ({ id: 20, trip_id: tripId, date })), 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)), getDay: vi.fn((dayId: number, tripId: number) => (dayId === 3 && tripId === 1 ? { id: 3 } : undefined)),
update: vi.fn((id: number) => ({ id })), update: vi.fn((id: number) => ({ id })),
appendDated: vi.fn((tripId: number): DatedDayAppend => ({
day: { id: 21, trip_id: tripId, day_number: 3, date: '2027-01-03', assignments: [], notes_items: [] } as DatedDayAppend['day'],
endDate: '2027-01-03', boundaries: null, trip: { id: tripId, end_date: '2027-01-03' },
})),
// The service's own fan-out is DAY-SVC-099; the stub sends one event through each sender.
announceDatedAppend: vi.fn((append: DatedDayAppend, send: Parameters<DaysService['announceDatedAppend']>[1]) => {
send.others('day:reordered', { day: append.day });
send.all('trip:updated', { trip: append.trip });
}),
} as unknown as DaysService & Record<string, ReturnType<typeof vi.fn>>; } as unknown as DaysService & Record<string, ReturnType<typeof vi.fn>>;
const removed: DayRemoval = { const removed: DayRemoval = {
dayId: 3, orderedIds: [4], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [], dayId: 3, orderedIds: [4], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [],
@@ -130,6 +139,36 @@ describe('DaysRpc', () => {
const res = (await f.host('db:write:days').dispatch(req('days.delete', { tripId: 1, dayId: 3 }), 42)) as RpcError; 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'); expect(res.error.code).not.toBe('BAD_PARAMS');
}); });
it('DAYS-RPC-007 a dated create appends as the bound user, answers with the day and fans out to every socket', async () => {
const f = build();
const res = await f.host('db:write:days').dispatch(req('days.create', { tripId: 1, input: { dated: true, notes: 'Late checkout' } }), 42);
expect(res).toMatchObject({ ok: true, result: { id: 21, date: '2027-01-03' } });
expect(f.days.appendDated).toHaveBeenCalledWith(1, 42, 'Late checkout');
expect(f.days.create).not.toHaveBeenCalled();
const send = vi.mocked(f.days.announceDatedAppend).mock.calls[0][1];
expect(send.all).toBe(send.others);
expect(f.realtime.broadcast.mock.calls.map((c) => c[1])).toEqual(['day:reordered', 'trip:updated']);
});
it('DAYS-RPC-008 a dated create on a trip without dates, or next to a position, is BAD_PARAMS and announces nothing', async () => {
const f = build();
vi.mocked(f.days.appendDated).mockImplementation(() => { throw new DayAppendError(NO_DATES_MESSAGE); });
const host = f.host('db:write:days');
const noDates = (await host.dispatch(req('days.create', { tripId: 1, input: { dated: true } }), 42)) as RpcError;
expect(noDates.error).toMatchObject({ code: 'BAD_PARAMS', message: NO_DATES_MESSAGE });
const mixed = (await host.dispatch(req('days.create', { tripId: 1, input: { dated: true, position: 2 } }), 42)) as RpcError;
expect(mixed.error.code).toBe('BAD_PARAMS');
expect(mixed.error.message).toContain('dated cannot be combined with date or position');
expect(f.days.appendDated).toHaveBeenCalledTimes(1);
// Anything else is not dressed up as bad params.
vi.mocked(f.days.appendDated).mockImplementation(() => { throw new Error('db is down'); });
const broken = (await host.dispatch(req('days.create', { tripId: 1, input: { dated: true } }), 42)) as RpcError;
expect(broken.error.code).not.toBe('BAD_PARAMS');
expect(f.realtime.broadcast).not.toHaveBeenCalled();
});
}); });
describe('ItineraryRpc', () => { describe('ItineraryRpc', () => {
+110 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Unit tests for the DI-discovered DaysMcp: the update_day and reorder_days * Unit tests for the DI-discovered DaysMcp: the update_day and reorder_days
* tools, create_day's mid-trip insert, and the trek://trips/{tripId}/days * tools, create_day's mid-trip insert and dated append, and the trek://trips/{tripId}/days
* resource (moved from resources.test.ts when the legacy registrar was ported). * resource (moved from resources.test.ts when the legacy registrar was ported).
* create_day's plain append is covered in tools-days-accommodations.test.ts. * create_day's plain append is covered in tools-days-accommodations.test.ts.
*/ */
@@ -42,6 +42,7 @@ import {
createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation, createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation,
} from '../../helpers/factories'; } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { DaysService } from '../../../src/nest/days/days.service';
beforeAll(() => { beforeAll(() => {
createTables(testDb); createTables(testDb);
@@ -376,6 +377,114 @@ describe('Tool: create_day (position)', () => {
}); });
}); });
// ---------------------------------------------------------------------------
// create_day (dated)
// ---------------------------------------------------------------------------
describe('Tool: create_day (dated)', () => {
const endDate = (tripId: number) =>
(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(tripId) as { end_date: string | null }).end_date;
it('appends the next date behind the dated days, extends the trip and answers with both', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
const [d1, d2] = dayIdsInOrder(trip.id);
const spare = createDay(testDb, trip.id);
let data = {} as { day: { id: number; date: string; notes: string | null }; trip: { end_date: string; day_count: number } };
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_day',
arguments: { tripId: trip.id, dated: true, notes: 'Late checkout' },
});
data = parseToolResult(result) as typeof data;
});
expect(data.day).toMatchObject({ date: '2025-06-03', notes: 'Late checkout' });
expect(data.trip).toMatchObject({ end_date: '2025-06-03', day_count: 4 });
expect(dayIdsInOrder(trip.id)).toEqual([d1, d2, data.day.id, spare.id]);
expect(dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03', null]);
expect(endDate(trip.id)).toBe('2025-06-03');
// The insert shape of day:reordered, so collaborators refetch, then the trip.
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:reordered', expect.objectContaining({ day: expect.objectContaining({ id: data.day.id }) }));
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'trip:updated', expect.objectContaining({ trip: expect.objectContaining({ end_date: '2025-06-03' }) }));
expect(broadcastMock).not.toHaveBeenCalledWith(trip.id, 'day:created', expect.any(Object));
});
it('refuses dated next to a position or a date, the way the REST contract does', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
await withHarness(user.id, async (h) => {
for (const extra of [{ position: 1 }, { date: '2025-07-01' }]) {
const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id, dated: true, ...extra } });
expect(result.isError).toBe(true);
expect((result.content as { text: string }[])[0].text).toContain('dated cannot be combined with date or position');
}
});
expect(dayIdsInOrder(trip.id)).toHaveLength(2);
expect(endDate(trip.id)).toBe('2025-06-02');
expect(broadcastMock).not.toHaveBeenCalled();
});
it('refuses a trip without dates and writes nothing', 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: 'create_day', arguments: { tripId: trip.id, dated: true } });
expect(result.isError).toBe(true);
expect((result.content as { text: string }[])[0].text).toContain('This trip has no dates');
});
expect(dayDatesInOrder(trip.id)).toEqual([null]);
expect(broadcastMock).not.toHaveBeenCalled();
});
it('lets an unexpected failure surface as one, not as a refusal of the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
const boom = vi.spyOn(DaysService.prototype, 'appendDated').mockImplementation(() => { throw new Error('disk full'); });
try {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id, dated: true } });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).not.toContain('This trip has no dates');
});
} finally {
boom.mockRestore();
}
expect(broadcastMock).not.toHaveBeenCalled();
});
it('blocks a demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id, dated: true } });
expect(result.isError).toBe(true);
});
expect(dayIdsInOrder(trip.id)).toHaveLength(2);
expect(endDate(trip.id)).toBe('2025-06-02');
});
it('an insert at a position announces the grown trip as well', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id, position: 1 } });
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'trip:updated', expect.objectContaining({ trip: expect.objectContaining({ end_date: '2025-06-03', day_count: 3 }) }));
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// reorder_days // reorder_days
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+69 -5
View File
@@ -3,7 +3,8 @@ import { HttpException } from '@nestjs/common';
import { DaysController } from '../../../src/nest/days/days.controller'; import { DaysController } from '../../../src/nest/days/days.controller';
import { DayNotesController } from '../../../src/nest/day-notes/day-notes.controller'; import { DayNotesController } from '../../../src/nest/day-notes/day-notes.controller';
import { DayNoteCreateDto, DayNoteUpdateDto } from '../../../src/nest/day-notes/day-notes.dto'; import { DayNoteCreateDto, DayNoteUpdateDto } from '../../../src/nest/day-notes/day-notes.dto';
import { DayReorderError } from '../../../src/nest/days/days.service'; import { DayReorderError, DayAppendError, NO_DATES_MESSAGE, type DatedDayAppend } from '../../../src/nest/days/days.service';
import { DayCreateDto } from '../../../src/nest/days/days.dto';
import { DayDeleteError, type DayRemoval, type DayRemovalService } from '../../../src/nest/days/day-removal.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 { DayReorderDto } from '../../../src/nest/days/days.dto';
import type { DaysService } from '../../../src/nest/days/days.service'; import type { DaysService } from '../../../src/nest/days/days.service';
@@ -59,13 +60,76 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)'
}); });
it('POST / with a position inserts + broadcasts day:reordered', () => { it('POST / with a position inserts, broadcasts day:reordered, then the grown trip', () => {
const insert = vi.fn().mockReturnValue({ id: 12 }); const create = vi.fn(); const broadcast = vi.fn(); const insert = vi.fn().mockReturnValue({ id: 12 }); const create = vi.fn(); const broadcast = vi.fn();
const svc = daysSvc({ insert, create, broadcast } as Partial<DaysService>); const getTripForViewer = vi.fn().mockReturnValue({ id: 5, end_date: '2026-07-04' });
expect(new DaysController(svc, removalSvc()).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } }); const svc = daysSvc({ insert, create, broadcast, getTripForViewer } as Partial<DaysService>);
expect(new DaysController(svc, removalSvc()).create(user, '5', { position: 0 }, 'sock'))
.toEqual({ day: { id: 12 }, trip: { id: 5, end_date: '2026-07-04' } });
expect(insert).toHaveBeenCalledWith('5', 0); expect(insert).toHaveBeenCalledWith('5', 0);
expect(create).not.toHaveBeenCalled(); expect(create).not.toHaveBeenCalled();
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { day: { id: 12 } }, 'sock'); expect(getTripForViewer).toHaveBeenCalledWith('5', 1);
expect(broadcast.mock.calls).toEqual([
['5', 'day:reordered', { day: { id: 12 } }, 'sock'],
['5', 'trip:updated', { trip: { id: 5, end_date: '2026-07-04' } }, 'sock'],
]);
// A trip that is gone by the time it is re-read is not announced.
broadcast.mockClear();
getTripForViewer.mockReturnValue(undefined);
new DaysController(svc, removalSvc()).create(user, '5', { position: 1 }, 'sock');
expect(broadcast.mock.calls.map(call => call[1])).toEqual(['day:reordered']);
});
describe('POST / dated', () => {
const appended: DatedDayAppend = {
day: { id: 14, trip_id: 5, day_number: 4, date: '2026-07-04', assignments: [], notes_items: [] } as DatedDayAppend['day'],
endDate: '2026-07-04', boundaries: null, trip: { id: 5, end_date: '2026-07-04' },
};
it('appends as the caller, announces with and without the socket, and answers with the day and the trip', () => {
const appendDated = vi.fn().mockReturnValue(appended);
const announceDatedAppend = vi.fn();
const broadcast = vi.fn(); const insert = vi.fn(); const create = vi.fn();
const svc = daysSvc({ appendDated, announceDatedAppend, broadcast, insert, create } as Partial<DaysService>);
expect(new DaysController(svc, removalSvc()).create(user, '5', { dated: true, notes: 'Late checkout' }, 'sock'))
.toEqual({ day: appended.day, trip: appended.trip });
expect(appendDated).toHaveBeenCalledWith('5', 1, 'Late checkout');
expect(insert).not.toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
const [sent, senders] = announceDatedAppend.mock.calls[0];
expect(sent).toBe(appended);
senders.all('roadtripBoundary:changed', { boundaries: [] });
senders.others('day:reordered', { day: appended.day });
expect(broadcast.mock.calls).toEqual([
['5', 'roadtripBoundary:changed', { boundaries: [] }, undefined],
['5', 'day:reordered', { day: appended.day }, 'sock'],
]);
});
it('400 with the service sentence for a trip without dates, and nothing is announced', () => {
const announceDatedAppend = vi.fn();
const svc = daysSvc({
appendDated: vi.fn(() => { throw new DayAppendError(NO_DATES_MESSAGE); }), announceDatedAppend,
} as Partial<DaysService>);
expect(thrown(() => new DaysController(svc, removalSvc()).create(user, '5', { dated: true }, 'sock')))
.toEqual({ status: 400, body: { error: NO_DATES_MESSAGE } });
expect(announceDatedAppend).not.toHaveBeenCalled();
});
it('rethrows anything else unchanged', () => {
const boom = new Error('db is down');
const svc = daysSvc({ appendDated: vi.fn(() => { throw boom; }) } as Partial<DaysService>);
expect(() => new DaysController(svc, removalSvc()).create(user, '5', { dated: true })).toThrow(boom);
});
it('the DTO refuses dated next to a position or a date before the handler runs', () => {
expect(DayCreateDto.schema.safeParse({ dated: true, position: 2 }).success).toBe(false);
expect(DayCreateDto.schema.safeParse({ dated: true, date: '2026-07-04' }).success).toBe(false);
expect(DayCreateDto.schema.safeParse({ dated: true }).success).toBe(true);
});
}); });
describe('PUT /reorder', () => { describe('PUT /reorder', () => {
+159 -2
View File
@@ -8,6 +8,7 @@
* stayed behind when the accommodation CRUD moved to accommodations/ — mostly * stayed behind when the accommodation CRUD moved to accommodations/ — mostly
* the SKIP paths (booking without a day, snapshot without a date, stay that * the SKIP paths (booking without a day, snapshot without a date, stay that
* must not be re-anchored), which the happy-path integration test never walks. * must not be re-anchored), which the happy-path integration test never walks.
* DAY-SVC-091 through DAY-SVC-097 and DAY-SVC-099 cover the dated append.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully. * Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/ */
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -52,10 +53,10 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { createTables } from '../../../src/db/schema'; import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations'; import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db'; import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation, createDayNote, createTag } from '../../helpers/factories'; import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation, createDayNote, createTag, createReservation } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service'; import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { DaysService, DayReorderError, addDays } from '../../../src/nest/days/days.service'; import { DaysService, DayReorderError, DayAppendError, NO_DATES_MESSAGE, addDays } from '../../../src/nest/days/days.service';
// Was days.bridge, deleted with the other three that had no consumer outside the // Was days.bridge, deleted with the other three that had no consumer outside the
// container. The assertions stayed; they point at the service now. // container. The assertions stayed; they point at the service now.
const bridgeGetDay = (id: string | number, tripId: string | number) => svc.getDay(id, tripId); const bridgeGetDay = (id: string | number, tripId: string | number) => svc.getDay(id, tripId);
@@ -900,3 +901,159 @@ describe('DaysService.canEdit', () => {
expect(withStub.canEdit(trip, { id: 2, role: 'user' } as never)).toBe(false); expect(withStub.canEdit(trip, { id: 2, role: 'user' } as never)).toBe(false);
}); });
}); });
/**
* appendDated: "Add day with date" in the reorder dialog, create_day with `dated`
* and the plugin RPC. The new day takes the calendar day after the last date of
* the trip and goes right behind the last dated day; nothing already there moves date.
*/
describe('DaysService.appendDated', () => {
type Row = { id: number; day_number: number; date: string | null };
const rows = (tripId: number): Row[] =>
testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as Row[];
const endDate = (tripId: number) =>
(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(tripId) as { end_date: string | null }).end_date;
const boundaries = (tripId: number) =>
testDb.prepare('SELECT day_number, from_assignment_id FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number').all(tripId);
it('DAY-SVC-091 appends the day after the end date, with its notes, and moves the end date to it', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-10-10', end_date: '2026-10-12' });
const before = rows(trip.id);
const result = svc.appendDated(trip.id, user.id, 'Late checkout');
expect(result.day).toMatchObject({ trip_id: trip.id, day_number: 4, date: '2026-10-13', notes: 'Late checkout', assignments: [], notes_items: [] });
expect(result.endDate).toBe('2026-10-13');
expect(result.boundaries).toBeNull();
expect(result.trip).toMatchObject({ id: trip.id, end_date: '2026-10-13', day_count: 4, is_owner: 1, feed_token: null });
expect(rows(trip.id)).toEqual([...before, { id: result.day.id, day_number: 4, date: '2026-10-13' }]);
expect(endDate(trip.id)).toBe('2026-10-13');
});
it('DAY-SVC-092 puts the new day in front of the days without a date, which keep their content and stay undated', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-10-10', end_date: '2026-10-11' });
const [d1, d2] = rows(trip.id);
const spare = createDay(testDb, trip.id, { title: 'Buffer day' });
const other = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const stop = createDayAssignment(testDb, spare.id, place.id);
const { day } = svc.appendDated(trip.id, user.id);
expect(rows(trip.id)).toEqual([
d1, d2,
{ id: day.id, day_number: 3, date: '2026-10-12' },
{ id: spare.id, day_number: 4, date: null },
{ id: other.id, day_number: 5, date: null },
]);
expect(testDb.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(stop.id)).toEqual({ day_id: spare.id });
expect(svc.getDay(spare.id, trip.id)).toMatchObject({ title: 'Buffer day' });
});
it('DAY-SVC-093 anchors on a day dated past the end date, so no date is given twice', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-10-10', end_date: '2026-10-11' });
const [, d2] = rows(trip.id);
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-10-14', d2.id);
const result = svc.appendDated(trip.id, user.id);
expect(result.day.date).toBe('2026-10-15');
expect(endDate(trip.id)).toBe('2026-10-15');
});
it('DAY-SVC-094 refuses a trip without dates and writes nothing', () => {
const { user } = createUser(testDb);
const dateless = createTrip(testDb, user.id);
createDay(testDb, dateless.id);
const halfDated = createTrip(testDb, user.id);
testDb.prepare('UPDATE trips SET start_date = ? WHERE id = ?').run('2026-10-10', halfDated.id);
for (const trip of [dateless, halfDated]) {
const before = rows(trip.id);
expect(() => svc.appendDated(trip.id, user.id)).toThrow(new DayAppendError(NO_DATES_MESSAGE));
expect(rows(trip.id)).toEqual(before);
expect(endDate(trip.id)).toBeNull();
}
});
it('DAY-SVC-095 refuses a day past the day limit and writes nothing', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// A full-length range without its 999 rows: the limit reads the range, not the rows.
testDb.prepare('UPDATE trips SET start_date = ?, end_date = ? WHERE id = ?').run('2026-01-01', addDays('2026-01-01', 998), trip.id);
createDay(testDb, trip.id, { date: '2026-01-01' });
const before = rows(trip.id);
expect(() => svc.appendDated(trip.id, user.id)).toThrow(new DayAppendError('A trip can span at most 999 days'));
expect(rows(trip.id)).toEqual(before);
expect(endDate(trip.id)).toBe(addDays('2026-01-01', 998));
});
it('DAY-SVC-096 moves the road trip boundaries of the days behind it back by one, the last one first', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-10-10', end_date: '2026-10-11' });
const [d1] = rows(trip.id);
const spareA = createDay(testDb, trip.id);
const spareB = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const stops = [d1, spareA, spareB].map(d => createDayAssignment(testDb, d.id, place.id));
const boundary = testDb.prepare(
'INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, ?, ?, NULL, 1)',
);
boundary.run(trip.id, 1, stops[0].id);
boundary.run(trip.id, 3, stops[1].id);
boundary.run(trip.id, 4, stops[2].id);
const result = svc.appendDated(trip.id, user.id);
const moved = [
{ day_number: 1, from_assignment_id: stops[0].id },
{ day_number: 4, from_assignment_id: stops[1].id },
{ day_number: 5, from_assignment_id: stops[2].id },
];
expect(boundaries(trip.id)).toEqual(moved);
expect(result.boundaries?.map(b => ({ day_number: b.day_number, from_assignment_id: b.from_assignment_id }))).toEqual(moved);
});
it('DAY-SVC-097 moves no booking, while insert() still dates the days without one', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-10-10', end_date: '2026-10-11' });
const [, d2] = rows(trip.id);
const reservation = createReservation(testDb, trip.id, { day_id: d2.id });
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2026-10-11T19:30', reservation.id);
const spare = createDay(testDb, trip.id);
svc.appendDated(trip.id, user.id);
expect(testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(reservation.id))
.toEqual({ day_id: d2.id, reservation_time: '2026-10-11T19:30' });
expect(rows(trip.id).find(r => r.id === spare.id)).toMatchObject({ day_number: 4, date: null });
// The older path is untouched: an insert re-dates every row, the spare day included.
const inserted = svc.insert(trip.id);
expect(rows(trip.id).find(r => r.id === spare.id)).toMatchObject({ date: '2026-10-13' });
expect(inserted).toMatchObject({ day_number: 5, date: '2026-10-14' });
});
it('DAY-SVC-099 announces the day to the others, moved boundaries to everyone and the trip to the others', () => {
const all = vi.fn();
const others = vi.fn();
const day = { id: 7, trip_id: 1, day_number: 3, date: '2026-10-12', assignments: [], notes_items: [] } as never;
const moved = [{ day_number: 4, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }];
svc.announceDatedAppend({ day, endDate: '2026-10-12', boundaries: moved, trip: { id: 1 } }, { all, others });
expect(others.mock.calls).toEqual([
['day:reordered', { day }],
['trip:updated', { trip: { id: 1 } }],
]);
expect(all.mock.calls).toEqual([['roadtripBoundary:changed', { boundaries: moved }]]);
all.mockClear();
others.mockClear();
svc.announceDatedAppend({ day, endDate: '2026-10-12', boundaries: null, trip: undefined }, { all, others });
expect(others.mock.calls).toEqual([['day:reordered', { day }]]);
expect(all).not.toHaveBeenCalled();
});
});
+31 -1
View File
@@ -1,4 +1,9 @@
import { dayCreateRequestSchema, dayNoteCreateRequestSchema, dayNoteUpdateRequestSchema } from './day.schema'; import {
DAY_CREATE_DATED_CONFLICT,
dayCreateRequestSchema,
dayNoteCreateRequestSchema,
dayNoteUpdateRequestSchema,
} from './day.schema';
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
@@ -7,6 +12,31 @@ describe('dayCreateRequestSchema', () => {
expect(dayCreateRequestSchema.safeParse({}).success).toBe(true); expect(dayCreateRequestSchema.safeParse({}).success).toBe(true);
expect(dayCreateRequestSchema.safeParse({ date: '2026-07-01', notes: 'n' }).success).toBe(true); expect(dayCreateRequestSchema.safeParse({ date: '2026-07-01', notes: 'n' }).success).toBe(true);
}); });
it('takes dated on its own or with notes', () => {
expect(dayCreateRequestSchema.safeParse({ dated: true }).success).toBe(true);
expect(dayCreateRequestSchema.safeParse({ dated: true, notes: 'Late checkout' }).success).toBe(true);
// An explicit false is a plain append, so it combines with anything.
expect(dayCreateRequestSchema.safeParse({ dated: false, position: 2, date: '2026-07-01' }).success).toBe(true);
});
it('refuses dated next to a position or a date, on the dated key', () => {
for (const body of [
{ dated: true, position: 2 },
{ dated: true, date: '2026-07-01' },
]) {
const parsed = dayCreateRequestSchema.safeParse(body);
expect(parsed.success).toBe(false);
expect(parsed.error?.issues[0]).toMatchObject({ message: DAY_CREATE_DATED_CONFLICT, path: ['dated'] });
}
});
it('keeps its fields reachable for the MCP tool, which builds its input from them', () => {
expect(dayCreateRequestSchema.shape.date.safeParse('2026-07-01').success).toBe(true);
expect(dayCreateRequestSchema.shape.notes.safeParse(undefined).success).toBe(true);
expect(dayCreateRequestSchema.shape.position.safeParse(0).success).toBe(false);
expect(dayCreateRequestSchema.shape.dated.safeParse(true).success).toBe(true);
});
}); });
describe('dayNoteCreateRequestSchema', () => { describe('dayNoteCreateRequestSchema', () => {
+21 -6
View File
@@ -52,12 +52,27 @@ export const daySchema = z.object({
}); });
export type Day = z.infer<typeof daySchema>; export type Day = z.infer<typeof daySchema>;
export const dayCreateRequestSchema = z.object({ /** The refusal for `dated` next to `date` or `position`, the same on REST, MCP and the plugin RPC. */
date: z.string().optional(), export const DAY_CREATE_DATED_CONFLICT = 'dated cannot be combined with date or position';
notes: z.string().optional(),
// 1-based slot to insert a new empty day at (omit to append at the end). /**
position: z.number().int().positive().optional(), * A new day: appended at the end, slotted in at `position`, or with `dated` the
}); * calendar day after the trip's last date, which extends the trip by one day.
* `dated` picks its own date and place (behind the last dated day, in front of
* the days without a date), so neither can be given next to it.
*/
export const dayCreateRequestSchema = z
.object({
date: z.string().optional(),
notes: z.string().optional(),
// 1-based slot to insert a new empty day at (omit to append at the end).
position: z.number().int().positive().optional(),
dated: z.boolean().optional(),
})
.refine((body) => !body.dated || (body.position === undefined && body.date === undefined), {
message: DAY_CREATE_DATED_CONFLICT,
path: ['dated'],
});
export type DayCreateRequest = z.infer<typeof dayCreateRequestSchema>; export type DayCreateRequest = z.infer<typeof dayCreateRequestSchema>;
/** Reorder whole days: the desired full sequence of this trip's day ids. */ /** Reorder whole days: the desired full sequence of this trip's day ids. */
+5
View File
@@ -112,5 +112,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'تغيير الأيام يتطلب اتصالًا', 'dayplan.daysOffline': 'تغيير الأيام يتطلب اتصالًا',
'dayplan.deleteDaySuccess': 'تم حذف اليوم', 'dayplan.deleteDaySuccess': 'تم حذف اليوم',
'dayplan.deleteDayError': 'تعذّر حذف اليوم', 'dayplan.deleteDayError': 'تعذّر حذف اليوم',
'dayplan.addUndatedDay': 'إضافة يوم بلا تاريخ',
'dayplan.addUndatedDayHint': 'يضيف يومًا بلا تاريخ في النهاية. تبقى تواريخ الرحلة كما هي.',
'dayplan.addDatedDay': 'إضافة {date}',
'dayplan.addDatedDayHint': 'يمدّد الرحلة يومًا واحدًا حتى {date}.',
'dayplan.tripExtended': 'تمت إضافة اليوم. تنتهي الرحلة الآن في {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Alterar os dias exige conexão', 'dayplan.daysOffline': 'Alterar os dias exige conexão',
'dayplan.deleteDaySuccess': 'Dia excluído', 'dayplan.deleteDaySuccess': 'Dia excluído',
'dayplan.deleteDayError': 'Falha ao excluir o dia', 'dayplan.deleteDayError': 'Falha ao excluir o dia',
'dayplan.addUndatedDay': 'Adicionar dia sem data',
'dayplan.addUndatedDayHint': 'Adiciona um dia sem data no final. As datas da viagem continuam as mesmas.',
'dayplan.addDatedDay': 'Adicionar {date}',
'dayplan.addDatedDayHint': 'Estende a viagem em um dia, até {date}.',
'dayplan.tripExtended': 'Dia adicionado. A viagem agora termina em {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Per canviar els dies cal connexió', 'dayplan.daysOffline': 'Per canviar els dies cal connexió',
'dayplan.deleteDaySuccess': 'Dia eliminat', 'dayplan.deleteDaySuccess': 'Dia eliminat',
'dayplan.deleteDayError': "No s'ha pogut eliminar el dia", 'dayplan.deleteDayError': "No s'ha pogut eliminar el dia",
'dayplan.addUndatedDay': 'Afegeix un dia sense data',
'dayplan.addUndatedDayHint': 'Afegeix un dia sense data al final. Les dates del viatge no canvien.',
'dayplan.addDatedDay': 'Afegeix {date}',
'dayplan.addDatedDayHint': 'Allarga el viatge un dia, fins al {date}.',
'dayplan.tripExtended': "S'ha afegit el dia. Ara el viatge acaba el {date}.",
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Změna dnů vyžaduje připojení', 'dayplan.daysOffline': 'Změna dnů vyžaduje připojení',
'dayplan.deleteDaySuccess': 'Den smazán', 'dayplan.deleteDaySuccess': 'Den smazán',
'dayplan.deleteDayError': 'Den se nepodařilo smazat', 'dayplan.deleteDayError': 'Den se nepodařilo smazat',
'dayplan.addUndatedDay': 'Přidat den bez data',
'dayplan.addUndatedDayHint': 'Přidá na konec den bez data. Termín cesty zůstane stejný.',
'dayplan.addDatedDay': 'Přidat {date}',
'dayplan.addDatedDayHint': 'Prodlouží cestu o jeden den, do {date}.',
'dayplan.tripExtended': 'Den přidán. Cesta teď končí {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Tage ändern geht nur mit Verbindung', 'dayplan.daysOffline': 'Tage ändern geht nur mit Verbindung',
'dayplan.deleteDaySuccess': 'Tag gelöscht', 'dayplan.deleteDaySuccess': 'Tag gelöscht',
'dayplan.deleteDayError': 'Tag konnte nicht gelöscht werden', 'dayplan.deleteDayError': 'Tag konnte nicht gelöscht werden',
'dayplan.addUndatedDay': 'Tag ohne Datum hinzufügen',
'dayplan.addUndatedDayHint': 'Hängt hinten einen Tag ohne Datum an. Die Reisedaten bleiben, wie sie sind.',
'dayplan.addDatedDay': '{date} hinzufügen',
'dayplan.addDatedDayHint': 'Verlängert die Reise um einen Tag, bis {date}.',
'dayplan.tripExtended': 'Tag hinzugefügt. Die Reise endet jetzt am {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Changing days needs a connection', 'dayplan.daysOffline': 'Changing days needs a connection',
'dayplan.deleteDaySuccess': 'Day deleted', 'dayplan.deleteDaySuccess': 'Day deleted',
'dayplan.deleteDayError': 'Failed to delete day', 'dayplan.deleteDayError': 'Failed to delete day',
'dayplan.addUndatedDay': 'Add day without date',
'dayplan.addUndatedDayHint': 'Adds a day without a date at the end. The trip dates stay as they are.',
'dayplan.addDatedDay': 'Add {date}',
'dayplan.addDatedDayHint': 'Extends the trip by one day, until {date}.',
'dayplan.tripExtended': 'Day added. The trip now ends on {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Cambiar los días requiere conexión', 'dayplan.daysOffline': 'Cambiar los días requiere conexión',
'dayplan.deleteDaySuccess': 'Día eliminado', 'dayplan.deleteDaySuccess': 'Día eliminado',
'dayplan.deleteDayError': 'No se pudo eliminar el día', 'dayplan.deleteDayError': 'No se pudo eliminar el día',
'dayplan.addUndatedDay': 'Añadir día sin fecha',
'dayplan.addUndatedDayHint': 'Añade un día sin fecha al final. Las fechas del viaje no cambian.',
'dayplan.addDatedDay': 'Añadir {date}',
'dayplan.addDatedDayHint': 'Alarga el viaje un día, hasta el {date}.',
'dayplan.tripExtended': 'Día añadido. El viaje termina ahora el {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Modifier les jours nécessite une connexion', 'dayplan.daysOffline': 'Modifier les jours nécessite une connexion',
'dayplan.deleteDaySuccess': 'Jour supprimé', 'dayplan.deleteDaySuccess': 'Jour supprimé',
'dayplan.deleteDayError': 'Échec de la suppression du jour', 'dayplan.deleteDayError': 'Échec de la suppression du jour',
'dayplan.addUndatedDay': 'Ajouter un jour sans date',
'dayplan.addUndatedDayHint': 'Ajoute un jour sans date à la fin. Les dates du voyage ne changent pas.',
'dayplan.addDatedDay': 'Ajouter {date}',
'dayplan.addDatedDayHint': "Prolonge le voyage d'un jour, jusqu'au {date}.",
'dayplan.tripExtended': 'Jour ajouté. Le voyage se termine désormais le {date}.',
}; };
export default dayplan; export default dayplan;
+6
View File
@@ -114,5 +114,11 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Η αλλαγή ημερών απαιτεί σύνδεση', 'dayplan.daysOffline': 'Η αλλαγή ημερών απαιτεί σύνδεση',
'dayplan.deleteDaySuccess': 'Η ημέρα διαγράφηκε', 'dayplan.deleteDaySuccess': 'Η ημέρα διαγράφηκε',
'dayplan.deleteDayError': 'Η διαγραφή της ημέρας απέτυχε', 'dayplan.deleteDayError': 'Η διαγραφή της ημέρας απέτυχε',
'dayplan.addUndatedDay': 'Προσθήκη ημέρας χωρίς ημερομηνία',
'dayplan.addUndatedDayHint':
'Προσθέτει στο τέλος μια ημέρα χωρίς ημερομηνία. Οι ημερομηνίες του ταξιδιού μένουν ίδιες.',
'dayplan.addDatedDay': 'Προσθήκη {date}',
'dayplan.addDatedDayHint': 'Επεκτείνει το ταξίδι κατά μία ημέρα, έως {date}.',
'dayplan.tripExtended': 'Η ημέρα προστέθηκε. Το ταξίδι τελειώνει πλέον στις {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'A napok módosításához kapcsolat szükséges', 'dayplan.daysOffline': 'A napok módosításához kapcsolat szükséges',
'dayplan.deleteDaySuccess': 'Nap törölve', 'dayplan.deleteDaySuccess': 'Nap törölve',
'dayplan.deleteDayError': 'Nem sikerült törölni a napot', 'dayplan.deleteDayError': 'Nem sikerült törölni a napot',
'dayplan.addUndatedDay': 'Nap hozzáadása dátum nélkül',
'dayplan.addUndatedDayHint': 'Egy dátum nélküli napot ad a végéhez. Az utazás dátumai nem változnak.',
'dayplan.addDatedDay': '{date} hozzáadása',
'dayplan.addDatedDayHint': 'Egy nappal meghosszabbítja az utazást. Új utolsó nap: {date}.',
'dayplan.tripExtended': 'Nap hozzáadva. Az utazás utolsó napja most: {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Mengubah hari memerlukan koneksi', 'dayplan.daysOffline': 'Mengubah hari memerlukan koneksi',
'dayplan.deleteDaySuccess': 'Hari dihapus', 'dayplan.deleteDaySuccess': 'Hari dihapus',
'dayplan.deleteDayError': 'Gagal menghapus hari', 'dayplan.deleteDayError': 'Gagal menghapus hari',
'dayplan.addUndatedDay': 'Tambah hari tanpa tanggal',
'dayplan.addUndatedDayHint': 'Menambahkan hari tanpa tanggal di akhir. Tanggal perjalanan tetap sama.',
'dayplan.addDatedDay': 'Tambah {date}',
'dayplan.addDatedDayHint': 'Memperpanjang perjalanan satu hari, hingga {date}.',
'dayplan.tripExtended': 'Hari ditambahkan. Perjalanan kini berakhir pada {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -115,5 +115,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Per modificare i giorni serve una connessione', 'dayplan.daysOffline': 'Per modificare i giorni serve una connessione',
'dayplan.deleteDaySuccess': 'Giorno eliminato', 'dayplan.deleteDaySuccess': 'Giorno eliminato',
'dayplan.deleteDayError': 'Eliminazione del giorno non riuscita', 'dayplan.deleteDayError': 'Eliminazione del giorno non riuscita',
'dayplan.addUndatedDay': 'Aggiungi giorno senza data',
'dayplan.addUndatedDayHint': 'Aggiunge un giorno senza data alla fine. Le date del viaggio restano invariate.',
'dayplan.addDatedDay': 'Aggiungi {date}',
'dayplan.addDatedDayHint': 'Allunga il viaggio di un giorno, fino a {date}.',
'dayplan.tripExtended': 'Giorno aggiunto. Il viaggio ora finisce il {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': '日の変更には接続が必要です', 'dayplan.daysOffline': '日の変更には接続が必要です',
'dayplan.deleteDaySuccess': '日を削除しました', 'dayplan.deleteDaySuccess': '日を削除しました',
'dayplan.deleteDayError': '日の削除に失敗しました', 'dayplan.deleteDayError': '日の削除に失敗しました',
'dayplan.addUndatedDay': '日付なしの日を追加',
'dayplan.addUndatedDayHint': '日付のない日を最後に追加します。旅行の日程は変わりません。',
'dayplan.addDatedDay': '{date}を追加',
'dayplan.addDatedDayHint': '旅行を1日延長し、{date}までにします。',
'dayplan.tripExtended': '日を追加しました。旅行の最終日は{date}になりました。',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': '날짜를 변경하려면 연결이 필요합니다', 'dayplan.daysOffline': '날짜를 변경하려면 연결이 필요합니다',
'dayplan.deleteDaySuccess': '날짜가 삭제되었습니다', 'dayplan.deleteDaySuccess': '날짜가 삭제되었습니다',
'dayplan.deleteDayError': '날짜를 삭제하지 못했습니다', 'dayplan.deleteDayError': '날짜를 삭제하지 못했습니다',
'dayplan.addUndatedDay': '날짜 없는 날 추가',
'dayplan.addUndatedDayHint': '날짜 없는 날을 마지막에 추가합니다. 여행 날짜는 그대로입니다.',
'dayplan.addDatedDay': '{date} 추가',
'dayplan.addDatedDayHint': '여행을 하루 늘려 {date}까지로 만듭니다.',
'dayplan.tripExtended': '날짜를 추가했습니다. 이제 여행은 {date}에 끝납니다.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Dagen wijzigen vraagt een verbinding', 'dayplan.daysOffline': 'Dagen wijzigen vraagt een verbinding',
'dayplan.deleteDaySuccess': 'Dag verwijderd', 'dayplan.deleteDaySuccess': 'Dag verwijderd',
'dayplan.deleteDayError': 'Dag verwijderen mislukt', 'dayplan.deleteDayError': 'Dag verwijderen mislukt',
'dayplan.addUndatedDay': 'Dag zonder datum toevoegen',
'dayplan.addUndatedDayHint': 'Voegt aan het eind een dag zonder datum toe. De reisdata blijven zoals ze zijn.',
'dayplan.addDatedDay': '{date} toevoegen',
'dayplan.addDatedDayHint': 'Verlengt de reis met één dag, tot {date}.',
'dayplan.tripExtended': 'Dag toegevoegd. De reis eindigt nu op {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -115,5 +115,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Zmiana dni wymaga połączenia', 'dayplan.daysOffline': 'Zmiana dni wymaga połączenia',
'dayplan.deleteDaySuccess': 'Dzień usunięty', 'dayplan.deleteDaySuccess': 'Dzień usunięty',
'dayplan.deleteDayError': 'Nie udało się usunąć dnia', 'dayplan.deleteDayError': 'Nie udało się usunąć dnia',
'dayplan.addUndatedDay': 'Dodaj dzień bez daty',
'dayplan.addUndatedDayHint': 'Dodaje na końcu dzień bez daty. Daty podróży się nie zmieniają.',
'dayplan.addDatedDay': 'Dodaj {date}',
'dayplan.addDatedDayHint': 'Wydłuża podróż o jeden dzień, do {date}.',
'dayplan.tripExtended': 'Dodano dzień. Podróż kończy się teraz {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Для изменения дней нужно подключение', 'dayplan.daysOffline': 'Для изменения дней нужно подключение',
'dayplan.deleteDaySuccess': 'День удалён', 'dayplan.deleteDaySuccess': 'День удалён',
'dayplan.deleteDayError': 'Не удалось удалить день', 'dayplan.deleteDayError': 'Не удалось удалить день',
'dayplan.addUndatedDay': 'Добавить день без даты',
'dayplan.addUndatedDayHint': 'Добавляет в конец день без даты. Даты поездки не меняются.',
'dayplan.addDatedDay': 'Добавить {date}',
'dayplan.addDatedDayHint': 'Продлевает поездку на один день, до {date}.',
'dayplan.tripExtended': 'День добавлен. Теперь поездка заканчивается {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Att ändra dagar kräver en anslutning', 'dayplan.daysOffline': 'Att ändra dagar kräver en anslutning',
'dayplan.deleteDaySuccess': 'Dagen togs bort', 'dayplan.deleteDaySuccess': 'Dagen togs bort',
'dayplan.deleteDayError': 'Kunde inte ta bort dagen', 'dayplan.deleteDayError': 'Kunde inte ta bort dagen',
'dayplan.addUndatedDay': 'Lägg till dag utan datum',
'dayplan.addUndatedDayHint': 'Lägger till en dag utan datum i slutet. Resans datum förblir desamma.',
'dayplan.addDatedDay': 'Lägg till {date}',
'dayplan.addDatedDayHint': 'Förlänger resan med en dag, till {date}.',
'dayplan.tripExtended': 'Dag tillagd. Resan slutar nu {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Günleri değiştirmek için bağlantı gerekir', 'dayplan.daysOffline': 'Günleri değiştirmek için bağlantı gerekir',
'dayplan.deleteDaySuccess': 'Gün silindi', 'dayplan.deleteDaySuccess': 'Gün silindi',
'dayplan.deleteDayError': 'Gün silinemedi', 'dayplan.deleteDayError': 'Gün silinemedi',
'dayplan.addUndatedDay': 'Tarihsiz gün ekle',
'dayplan.addUndatedDayHint': 'Sona tarihsiz bir gün ekler. Seyahat tarihleri aynı kalır.',
'dayplan.addDatedDay': '{date} ekle',
'dayplan.addDatedDayHint': 'Seyahati bir gün uzatır. Yeni son gün: {date}.',
'dayplan.tripExtended': 'Gün eklendi. Seyahatin yeni son günü: {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Для зміни днів потрібне з’єднання', 'dayplan.daysOffline': 'Для зміни днів потрібне з’єднання',
'dayplan.deleteDaySuccess': 'День видалено', 'dayplan.deleteDaySuccess': 'День видалено',
'dayplan.deleteDayError': 'Не вдалося видалити день', 'dayplan.deleteDayError': 'Не вдалося видалити день',
'dayplan.addUndatedDay': 'Додати день без дати',
'dayplan.addUndatedDayHint': 'Додає в кінець день без дати. Дати подорожі не змінюються.',
'dayplan.addDatedDay': 'Додати {date}',
'dayplan.addDatedDayHint': 'Подовжує подорож на один день, до {date}.',
'dayplan.tripExtended': 'День додано. Тепер подорож закінчується {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Cần có kết nối để thay đổi ngày', 'dayplan.daysOffline': 'Cần có kết nối để thay đổi ngày',
'dayplan.deleteDaySuccess': 'Đã xóa ngày', 'dayplan.deleteDaySuccess': 'Đã xóa ngày',
'dayplan.deleteDayError': 'Không thể xóa ngày', 'dayplan.deleteDayError': 'Không thể xóa ngày',
'dayplan.addUndatedDay': 'Thêm ngày không có ngày tháng',
'dayplan.addUndatedDayHint': 'Thêm một ngày không có ngày tháng vào cuối. Ngày của chuyến đi giữ nguyên.',
'dayplan.addDatedDay': 'Thêm {date}',
'dayplan.addDatedDayHint': 'Kéo dài chuyến đi thêm một ngày, đến {date}.',
'dayplan.tripExtended': 'Đã thêm ngày. Chuyến đi giờ kết thúc vào {date}.',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -111,5 +111,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': '變更日期需要網路連線', 'dayplan.daysOffline': '變更日期需要網路連線',
'dayplan.deleteDaySuccess': '已刪除這一天', 'dayplan.deleteDaySuccess': '已刪除這一天',
'dayplan.deleteDayError': '刪除這一天失敗', 'dayplan.deleteDayError': '刪除這一天失敗',
'dayplan.addUndatedDay': '新增無日期的一天',
'dayplan.addUndatedDayHint': '在最後新增一天沒有日期的行程。旅程日期不變。',
'dayplan.addDatedDay': '新增 {date}',
'dayplan.addDatedDayHint': '旅程延長一天,到 {date} 為止。',
'dayplan.tripExtended': '已新增一天。旅程現在於 {date} 結束。',
}; };
export default dayplan; export default dayplan;
+5
View File
@@ -111,5 +111,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': '更改日期需要网络连接', 'dayplan.daysOffline': '更改日期需要网络连接',
'dayplan.deleteDaySuccess': '已删除这一天', 'dayplan.deleteDaySuccess': '已删除这一天',
'dayplan.deleteDayError': '删除这一天失败', 'dayplan.deleteDayError': '删除这一天失败',
'dayplan.addUndatedDay': '添加无日期的一天',
'dayplan.addUndatedDayHint': '在末尾添加一天没有日期的行程。旅行日期不变。',
'dayplan.addDatedDay': '添加 {date}',
'dayplan.addDatedDayHint': '旅行延长一天,到 {date} 为止。',
'dayplan.tripExtended': '已添加一天。旅行现在于 {date} 结束。',
}; };
export default dayplan; export default dayplan;
+1
View File
@@ -48,6 +48,7 @@ export * from './place/track-colors';
export * from './collection/collection.schema'; export * from './collection/collection.schema';
export * from './collection/collection-file.schema'; export * from './collection/collection-file.schema';
export * from './trip/trip.schema'; export * from './trip/trip.schema';
export * from './trip/day-grid';
export * from './trip-invite/trip-invite.schema'; export * from './trip-invite/trip-invite.schema';
export * from './collab/collab.schema'; export * from './collab/collab.schema';
export * from './file/file.schema'; export * from './file/file.schema';
+70
View File
@@ -0,0 +1,70 @@
import { addIsoDays, nextTripDate, planDatedAppend } from './day-grid';
import { MAX_TRIP_DAYS } from './trip.schema';
import { describe, expect, it } from 'vitest';
describe('addIsoDays', () => {
it('steps over the ends of months and years, and back', () => {
expect(addIsoDays('2026-01-31', 1)).toBe('2026-02-01');
expect(addIsoDays('2026-12-31', 1)).toBe('2027-01-01');
expect(addIsoDays('2027-01-01', -1)).toBe('2026-12-31');
expect(addIsoDays('2026-06-07', 0)).toBe('2026-06-07');
});
it('knows the leap day, and a year without one', () => {
expect(addIsoDays('2028-02-28', 1)).toBe('2028-02-29');
expect(addIsoDays('2028-02-29', 1)).toBe('2028-03-01');
expect(addIsoDays('2027-02-28', 1)).toBe('2027-03-01');
});
it('counts calendar days across a daylight saving change', () => {
// Europe moves its clocks on these Sundays; a local-time step would slip a day.
expect(addIsoDays('2026-03-28', 2)).toBe('2026-03-30');
expect(addIsoDays('2026-10-24', 2)).toBe('2026-10-26');
});
});
describe('nextTripDate', () => {
it('is the day after the end date when no day lies beyond it', () => {
expect(nextTripDate('2026-10-12', ['2026-10-10', '2026-10-11', '2026-10-12', null])).toBe('2026-10-13');
expect(nextTripDate('2026-10-12', [])).toBe('2026-10-13');
});
it('is the day after the latest day date when that lies past the end date', () => {
expect(nextTripDate('2026-10-12', ['2026-10-12', '2026-10-15', undefined, '2026-10-14'])).toBe('2026-10-16');
});
it('is null for a trip without an end date', () => {
expect(nextTripDate(null, ['2026-10-12'])).toBeNull();
expect(nextTripDate(undefined, [])).toBeNull();
expect(nextTripDate('', [])).toBeNull();
});
});
describe('planDatedAppend', () => {
it('gives the next date and lets the trip grow to it', () => {
expect(planDatedAppend({ start_date: '2026-10-10', end_date: '2026-10-12' }, ['2026-10-12'])).toEqual({
date: '2026-10-13',
fits: true,
});
});
it('refuses nothing up to the day limit and the day past it', () => {
const start = '2026-01-01';
const lastAllowed = addIsoDays(start, MAX_TRIP_DAYS - 1);
expect(planDatedAppend({ start_date: start, end_date: addIsoDays(lastAllowed, -1) }, [])).toEqual({
date: lastAllowed,
fits: true,
});
expect(planDatedAppend({ start_date: start, end_date: lastAllowed }, [])).toEqual({
date: addIsoDays(lastAllowed, 1),
fits: false,
});
});
it('has nothing to plan for a trip missing either date', () => {
expect(planDatedAppend({ start_date: null, end_date: '2026-10-12' }, [])).toBeNull();
expect(planDatedAppend({ start_date: '2026-10-10', end_date: null }, [])).toBeNull();
expect(planDatedAppend({}, ['2026-10-10'])).toBeNull();
});
});
+67
View File
@@ -0,0 +1,67 @@
import { MAX_TRIP_DAYS, tripSpanDays } from './trip.schema';
/**
* Pure rules for the day rows of a dated trip, for both sides of the wire. The
* server writes by them and the planner shows by them, so a date the planner
* promises before a write is the date the write stores.
*/
const MS_PER_DAY = 86_400_000;
/**
* Add `n` calendar days to a YYYY-MM-DD date, entirely in UTC.
*
* Never through a local-time Date: `new Date('2026-06-07T00:00:00')` is local
* midnight, and the round trip through toISOString() lands on the day before
* wherever the clock sits east of Greenwich.
*/
export function addIsoDays(date: string, n: number): string {
const [y = NaN, m = NaN, d = NaN] = date.split('-').map(Number);
const next = new Date(Date.UTC(y, m - 1, d) + n * MS_PER_DAY);
const yyyy = next.getUTCFullYear();
const mm = String(next.getUTCMonth() + 1).padStart(2, '0');
const dd = String(next.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
/** The day after the later of `endDate` and the latest date a day carries. ISO dates compare as strings. */
function dayAfterLatest(endDate: string, dayDates: readonly (string | null | undefined)[]): string {
const latest = dayDates.reduce<string>((last, date) => (date && date > last ? date : last), endDate);
return addIsoDays(latest, 1);
}
/**
* The date a day added "with a date" gets: the day after the trip's end date,
* or after the latest date a day carries when one lies further out. Null for a
* trip without an end date.
*
* The two agree on any trip the planner wrote. Reading the days as well keeps
* the new day off a date another day already has, whatever state the range is in.
*/
export function nextTripDate(
endDate: string | null | undefined,
dayDates: readonly (string | null | undefined)[],
): string | null {
return endDate ? dayAfterLatest(endDate, dayDates) : null;
}
/** A day added with a date: the date it gets, and whether the trip may still grow to it. */
export interface DatedAppendPlan {
date: string;
fits: boolean;
}
/**
* What adding the next dated day would do to a trip. Null for a trip without
* dates, which only takes days without one. `fits` is false when the longer
* range would pass MAX_TRIP_DAYS. The server refuses by this and the planner
* greys its button by it, so the two cannot disagree about the limit.
*/
export function planDatedAppend(
trip: { start_date?: string | null; end_date?: string | null },
dayDates: readonly (string | null | undefined)[],
): DatedAppendPlan | null {
if (!trip.start_date || !trip.end_date) return null;
const date = dayAfterLatest(trip.end_date, dayDates);
return { date, fits: tripSpanDays(trip.start_date, date) <= MAX_TRIP_DAYS };
}
+10 -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). - **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. - **Expand / Collapse all** — toggles all day sections open or closed at once.
- **Undo** — reverses the last drag, reorder, or assign action. - **Undo** — reverses the last drag, reorder, or assign action.
- **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). - **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 [Adding a day](#adding-a-day) and [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. - **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. 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,6 +88,15 @@ 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). - **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. - **Travel mode** — Driving or Walking for that day, plus any travel mode a plugin adds.
## Adding a day
The **Reorder days** dialog adds a day at its foot; on a phone the day sheet does the same below its list. On a trip with dates there are two ways, each with a line that says what it does:
- **Add day without date** puts a day without a date at the end. The trip dates stay as they are, which suits a buffer day you have not placed yet.
- **Add** followed by the next date (for example **Add Tue, Oct 13**) adds the calendar day after the last date of the trip and extends the trip to it. The new day goes right behind the last dated day, so days without a date move one place back and keep their plans. No existing day or booking changes its date.
A trip without dates has a single **Add day** button. Adding a day needs a connection, and a trip that already spans the most days a trip can have cannot grow by another date.
## Deleting a day ## 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: 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:
+1 -1
View File
@@ -78,7 +78,7 @@ Requires `trips:read` or `trips:write` scope.
| Tool | Description | | Tool | Description |
|---|---| |---|---|
| `update_day` | Set or clear a day's title. | | `update_day` | Set or clear a day's title. |
| `create_day` | Add a new day to a trip with optional date and notes. | | `create_day` | Add a new day to a trip with optional date and notes. With `position` an empty day is slotted in at that place. With `dated` the trip grows by the calendar day after its last date: the new day gets that date and goes right behind the last dated day, days without a date move one place back, and the end date of the trip moves to the new day. `dated` only works on a trip with dates and cannot be combined with `date` or `position`. |
| `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. | | `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`. | | `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. | | `set_day_default_transport_mode` | Set the whole-day default travel mode. Per-leg modes still override it. Pass `null` to clear. |