From f46cc8a98ec4d944a0e7a668241f587ec7727067 Mon Sep 17 00:00:00 2001
From: Maurice <61554723+mauriceboe@users.noreply.github.com>
Date: Fri, 12 Jun 2026 00:17:49 +0200
Subject: [PATCH] Reorder whole days and insert a day (#589) (#1148)
* feat(days): reorder whole days and insert a day at a position
Adds reorderDays + insertDay to the day service and a PUT /days/reorder route
(plus an optional position on create). Day rows stay stable so a day's
assignments, notes, bookings and accommodations ride along by id; on a dated
trip the calendar dates stay pinned to their slots while the content moves
across them, and each booking's date is re-stamped onto its day's new date
(time-of-day preserved) so day_id stays consistent. Renumbering uses the
two-phase write to avoid the UNIQUE(trip_id, day_number) collision, and a move
that would invert an accommodation's check-in/out span is rejected.
* feat(planner): reorder days from a toolbar popup, and add days
A new toolbar button opens a popup listing the days; drag a row by its grip or
use the up/down arrows to reorder, and add a day from there. Reorders apply
optimistically with rollback and sync over WebSocket; the day headers are left
untouched, so the existing place drop-targets are unaffected.
* i18n: add day-reorder strings across all languages
---
client/src/api/client.ts | 3 +-
.../src/components/Planner/DayPlanSidebar.tsx | 11 +-
.../Planner/DayPlanSidebarToolbar.tsx | 42 +++-
.../components/Planner/DayReorderPopup.tsx | 137 +++++++++++
client/src/pages/TripPlannerPage.tsx | 6 +-
.../src/pages/tripPlanner/useTripPlanner.ts | 19 +-
client/src/store/slices/daysSlice.ts | 58 +++++
client/src/store/slices/remoteEventHandler.ts | 19 ++
client/src/store/tripStore.ts | 4 +
server/src/nest/days/days.controller.ts | 38 +++-
server/src/nest/days/days.service.ts | 8 +
server/src/services/dayService.ts | 214 ++++++++++++++++++
server/tests/integration/dayReorder.test.ts | 134 +++++++++++
shared/src/day/day.schema.ts | 8 +
shared/src/i18n/ar/dayplan.ts | 9 +
shared/src/i18n/br/dayplan.ts | 9 +
shared/src/i18n/cs/dayplan.ts | 9 +
shared/src/i18n/de/dayplan.ts | 9 +
shared/src/i18n/en/dayplan.ts | 9 +
shared/src/i18n/es/dayplan.ts | 9 +
shared/src/i18n/fr/dayplan.ts | 9 +
shared/src/i18n/gr/dayplan.ts | 9 +
shared/src/i18n/hu/dayplan.ts | 9 +
shared/src/i18n/id/dayplan.ts | 9 +
shared/src/i18n/it/dayplan.ts | 9 +
shared/src/i18n/ja/dayplan.ts | 9 +
shared/src/i18n/ko/dayplan.ts | 9 +
shared/src/i18n/nl/dayplan.ts | 9 +
shared/src/i18n/pl/dayplan.ts | 9 +
shared/src/i18n/ru/dayplan.ts | 9 +
shared/src/i18n/tr/dayplan.ts | 9 +
shared/src/i18n/uk/dayplan.ts | 9 +
shared/src/i18n/zh-TW/dayplan.ts | 9 +
shared/src/i18n/zh/dayplan.ts | 9 +
34 files changed, 872 insertions(+), 9 deletions(-)
create mode 100644 client/src/components/Planner/DayReorderPopup.tsx
create mode 100644 client/src/store/slices/daysSlice.ts
create mode 100644 server/tests/integration/dayReorder.test.ts
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index f2840ac9..5fe940ea 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -18,7 +18,7 @@ import {
type TripAddMemberRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
- type DayCreateRequest, type DayUpdateRequest,
+ type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
type PlaceCreateRequest, type PlaceUpdateRequest,
type ReservationCreateRequest, type ReservationUpdateRequest,
type AccommodationCreateRequest, type AccommodationUpdateRequest,
@@ -341,6 +341,7 @@ export const daysApi = {
create: (tripId: number | string, data: DayCreateRequest) => 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),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
+ reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
}
export const placesApi = {
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index ec6d09ea..41f54f60 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -51,6 +51,8 @@ interface DayPlanSidebarProps {
onDayDetail: (day: Day) => void
accommodations?: Accommodation[]
onReorder: (dayId: number, orderedIds: number[]) => void
+ onReorderDays?: (orderedIds: number[]) => void
+ onAddDay?: (position?: number) => void
onUpdateDayTitle: (dayId: number, title: string) => void
onRouteCalculated: (route: RouteResult | null) => void
onAssignToDay: (placeId: number, dayId: number, position?: number) => void
@@ -96,7 +98,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId, selectedAssignmentId,
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
- onReorder, onUpdateDayTitle, onRouteCalculated,
+ onReorder, onReorderDays, onAddDay, onUpdateDayTitle, onRouteCalculated,
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [],
visibleConnectionIds = [],
@@ -866,6 +868,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onDayDetail,
accommodations,
onReorder,
+ onReorderDays,
+ onAddDay,
onUpdateDayTitle,
onRouteCalculated,
onAssignToDay,
@@ -1010,6 +1014,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onDayDetail,
accommodations,
onReorder,
+ onReorderDays,
+ onAddDay,
onUpdateDayTitle,
onRouteCalculated,
onAssignToDay,
@@ -1161,6 +1167,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
undoHover={undoHover}
setUndoHover={setUndoHover}
lastActionLabel={lastActionLabel}
+ canEditDays={canEditDays}
+ onReorderDays={onReorderDays}
+ onAddDay={onAddDay}
/>
{/* Tagesliste */}
diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.tsx
index 47f1bbfb..f828072b 100644
--- a/client/src/components/Planner/DayPlanSidebarToolbar.tsx
+++ b/client/src/components/Planner/DayPlanSidebarToolbar.tsx
@@ -1,5 +1,7 @@
-import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2 } from 'lucide-react'
+import { useState } from 'react'
+import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown } from 'lucide-react'
import { downloadTripPDF } from '../PDF/TripPDF'
+import { DayReorderPopup } from './DayReorderPopup'
import Tooltip from '../shared/Tooltip'
import { useToast } from '../shared/Toast'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
@@ -27,13 +29,18 @@ interface DayPlanSidebarToolbarProps {
undoHover: boolean
setUndoHover: (v: boolean) => void
lastActionLabel: string | null
+ canEditDays?: boolean
+ onReorderDays?: (orderedIds: number[]) => void
+ onAddDay?: (position?: number) => void
}
export function DayPlanSidebarToolbar({
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
+ canEditDays, onReorderDays, onAddDay,
}: DayPlanSidebarToolbarProps) {
+ const [reorderOpen, setReorderOpen] = useState(false)
return (
@@ -197,6 +204,39 @@ export function DayPlanSidebarToolbar({
)}
)}
+ {canEditDays && onReorderDays && onAddDay && days.length > 0 && (
+
+
+
+
+ {reorderOpen && (
+
onAddDay()}
+ onClose={() => setReorderOpen(false)}
+ />
+ )}
+
+ )}
)
diff --git a/client/src/components/Planner/DayReorderPopup.tsx b/client/src/components/Planner/DayReorderPopup.tsx
new file mode 100644
index 00000000..48af1942
--- /dev/null
+++ b/client/src/components/Planner/DayReorderPopup.tsx
@@ -0,0 +1,137 @@
+import { useState } from 'react'
+import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
+import type { Day } from '../../types'
+
+interface DayReorderPopupProps {
+ days: Day[]
+ t: (key: string, params?: Record) => string
+ locale: string
+ onReorder: (orderedIds: number[]) => void
+ onAddDay: () => void
+ onClose: () => void
+}
+
+/**
+ * Compact panel for moving whole days around: drag a row by its grip or use the
+ * up/down arrows, and add a day at the end. Day headers stay untouched — this is
+ * the single surface for ordering. Reorders are applied optimistically by the
+ * store, so the list reflects each move immediately.
+ */
+export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
+ const [dragIndex, setDragIndex] = useState(null)
+ const [overIndex, setOverIndex] = useState(null)
+
+ const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
+
+ const label = (day: Day, index: number) => {
+ if (day.title) return day.title
+ if (day.date) {
+ const d = new Date(day.date + 'T00:00:00')
+ return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
+ }
+ return t('dayplan.dayN', { n: index + 1 })
+ }
+
+ const move = (from: number, to: number) => {
+ if (to < 0 || to >= ordered.length || from === to) return
+ const ids = ordered.map(d => d.id)
+ const [moved] = ids.splice(from, 1)
+ ids.splice(to, 0, moved)
+ onReorder(ids)
+ }
+
+ const cellBtn = {
+ display: 'grid', placeItems: 'center', width: 26, height: 26,
+ border: '1px solid var(--border-faint)', borderRadius: 7,
+ background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
+ } as const
+
+ return (
+ <>
+ {/* outside-click catcher */}
+
+ e.stopPropagation()}
+ style={{
+ position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 251,
+ width: 290, maxHeight: 360, display: 'flex', flexDirection: 'column',
+ background: 'var(--bg-card, white)', color: 'var(--text-primary)',
+ border: '1px solid var(--border-faint)', borderRadius: 12,
+ boxShadow: '0 12px 32px rgba(0,0,0,0.18)', overflow: 'hidden',
+ }}
+ >
+
+
{t('dayplan.reorderTitle')}
+
+
+
+ {t('dayplan.reorderHint')}
+
+
+
+ {ordered.map((day, index) => (
+
setDragIndex(index)}
+ onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
+ onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
+ onDrop={e => {
+ e.preventDefault()
+ if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
+ setDragIndex(null); setOverIndex(null)
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px',
+ borderRadius: 8, marginTop: 2,
+ background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'transparent',
+ opacity: dragIndex === index ? 0.5 : 1,
+ outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
+ outlineOffset: -2,
+ }}
+ >
+
+
+ {index + 1}
+
+
+ {label(day, index)}
+
+
+
+
+ ))}
+
+
+ >
+ )
+}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 4a5e9f6a..323049d8 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -199,7 +199,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
- handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
+ handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
@@ -355,6 +355,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
onSelectDay={handleSelectDay}
onPlaceClick={handlePlaceClick}
onReorder={handleReorder}
+ onReorderDays={handleReorderDays}
+ onAddDay={handleAddDay}
onUpdateDayTitle={handleUpdateDayTitle}
onAssignToDay={handleAssignToDay}
onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }}
@@ -606,7 +608,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left'
- ?
{ handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || 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 }} />
+ ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || 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 }} />
: { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index d1e9f420..881dbb5b 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -541,6 +541,23 @@ export function useTripPlanner() {
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast])
+ const handleReorderDays = useCallback((orderedIds: number[]) => {
+ const prevIds = (useTripStore.getState().days || [])
+ .slice().sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)).map(d => d.id)
+ tripActions.reorderDays(tripId, orderedIds)
+ .then(() => {
+ pushUndo(t('dayplan.reorderUndo'), async () => {
+ await tripActions.reorderDays(tripId, prevIds)
+ })
+ })
+ .catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError')))
+ }, [tripId, toast, pushUndo])
+
+ const handleAddDay = useCallback((position?: number) => {
+ tripActions.insertDay(tripId, position)
+ .catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.addDayError')))
+ }, [tripId, toast])
+
const handleSaveReservation = async (data: Record & { title: string }) => {
try {
if (editingReservation) {
@@ -661,7 +678,7 @@ export function useTripPlanner() {
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
- handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
+ handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
diff --git a/client/src/store/slices/daysSlice.ts b/client/src/store/slices/daysSlice.ts
new file mode 100644
index 00000000..5fdc030b
--- /dev/null
+++ b/client/src/store/slices/daysSlice.ts
@@ -0,0 +1,58 @@
+import { daysApi } from '../../api/client'
+import type { StoreApi } from 'zustand'
+import type { TripStoreState } from '../tripStore'
+import type { Day } from '../../types'
+import { getApiErrorMessage } from '../../types'
+
+type SetState = StoreApi['setState']
+type GetState = StoreApi['getState']
+
+export interface DaysSlice {
+ reorderDays: (tripId: number | string, orderedIds: number[]) => Promise
+ insertDay: (tripId: number | string, position?: number) => Promise
+}
+
+export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({
+ // Move whole days. Day rows stay stable (assignments/notes/bookings ride along
+ // by id); only positions change and, on a dated trip, dates stay pinned to
+ // their slots while the content moves across them. Optimistically reorder the
+ // list, then refresh to pull the server-side re-stamped dates + booking times.
+ reorderDays: async (tripId, orderedIds) => {
+ const prevDays = get().days
+ const byId = new Map(prevDays.map(d => [d.id, d]))
+ const sortedDates = prevDays.map(d => d.date).filter((d): d is string => !!d).sort()
+ const optimistic = orderedIds
+ .map((id, i) => {
+ const d = byId.get(id)
+ if (!d) return null
+ return { ...d, day_number: i + 1, date: sortedDates.length ? (sortedDates[i] ?? null) : d.date }
+ })
+ .filter((d): d is NonNullable => d !== null)
+
+ set({ days: optimistic })
+
+ try {
+ await daysApi.reorder(tripId, orderedIds)
+ await get().refreshDays(tripId)
+ await get().loadReservations(tripId)
+ } catch (err: unknown) {
+ set({ days: prevDays })
+ throw new Error(getApiErrorMessage(err, 'Error reordering days'))
+ }
+ },
+
+ // Insert a new empty day at a 1-based position (omit to append). On a dated
+ // trip this extends the trip by one day and re-pins dates server-side.
+ insertDay: async (tripId, position) => {
+ const prevDays = get().days
+ try {
+ const result = await daysApi.create(tripId, { position })
+ await get().refreshDays(tripId)
+ await get().loadReservations(tripId)
+ return result.day
+ } catch (err: unknown) {
+ set({ days: prevDays })
+ throw new Error(getApiErrorMessage(err, 'Error adding day'))
+ }
+ },
+})
diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts
index 8cd3d5fd..b5136198 100644
--- a/client/src/store/slices/remoteEventHandler.ts
+++ b/client/src/store/slices/remoteEventHandler.ts
@@ -283,6 +283,15 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
dayNotes: newDayNotes,
}
}
+ case 'day:reordered': {
+ // Apply the new order instantly when we know all ids; the authoritative
+ // dates + re-stamped booking times are pulled by the refresh below.
+ const orderedIds = payload.orderedIds as number[] | undefined
+ if (!orderedIds || orderedIds.length !== state.days.length) return {}
+ const byId = new Map(state.days.map(d => [d.id, d]))
+ if (!orderedIds.every(id => byId.has(id))) return {}
+ return { days: orderedIds.map((id, i) => ({ ...byId.get(id)!, day_number: i + 1 })) }
+ }
// Day Notes
case 'dayNote:created': {
@@ -442,6 +451,16 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
}
})
+ // A reorder/insert re-pins dates and re-stamps booking times server-side, so
+ // pull the authoritative days + reservations for collaborators.
+ if (type === 'day:reordered') {
+ const tripId = get().trip?.id
+ if (tripId) {
+ get().refreshDays(tripId)
+ get().loadReservations(tripId)
+ }
+ }
+
// Write the change through to IndexedDB using the post-update state
writeToDexie(type, payload as Record, get())
}
diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts
index 3756d02f..e5f328c4 100644
--- a/client/src/store/tripStore.ts
+++ b/client/src/store/tripStore.ts
@@ -9,6 +9,7 @@ import { packingRepo } from '../repo/packingRepo'
import { todoRepo } from '../repo/todoRepo'
import { createPlacesSlice } from './slices/placesSlice'
import { createAssignmentsSlice } from './slices/assignmentsSlice'
+import { createDaysSlice } from './slices/daysSlice'
import { createDayNotesSlice } from './slices/dayNotesSlice'
import { createPackingSlice } from './slices/packingSlice'
import { createTodoSlice } from './slices/todoSlice'
@@ -24,6 +25,7 @@ import type {
import { getApiErrorMessage } from '../types'
import type { PlacesSlice } from './slices/placesSlice'
import type { AssignmentsSlice } from './slices/assignmentsSlice'
+import type { DaysSlice } from './slices/daysSlice'
import type { DayNotesSlice } from './slices/dayNotesSlice'
import type { PackingSlice } from './slices/packingSlice'
import type { TodoSlice } from './slices/todoSlice'
@@ -34,6 +36,7 @@ import type { FilesSlice } from './slices/filesSlice'
export interface TripStoreState
extends PlacesSlice,
AssignmentsSlice,
+ DaysSlice,
DayNotesSlice,
PackingSlice,
TodoSlice,
@@ -184,6 +187,7 @@ export const useTripStore = create((set, get) => ({
...createPlacesSlice(set, get),
...createAssignmentsSlice(set, get),
+ ...createDaysSlice(set, get),
...createDayNotesSlice(set, get),
...createPackingSlice(set, get),
...createTodoSlice(set, get),
diff --git a/server/src/nest/days/days.controller.ts b/server/src/nest/days/days.controller.ts
index be5eb265..1a669039 100644
--- a/server/src/nest/days/days.controller.ts
+++ b/server/src/nest/days/days.controller.ts
@@ -12,6 +12,7 @@ import {
} from '@nestjs/common';
import type { User } from '../../types';
import { DaysService } from './days.service';
+import { DayReorderError } from '../../services/dayService';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -52,16 +53,47 @@ export class DaysController {
create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
- @Body() body: { date?: string; notes?: string },
+ @Body() body: { date?: string; notes?: string; position?: number },
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
- const day = this.days.create(tripId, body.date, body.notes);
- this.days.broadcast(tripId, 'day:created', { day }, socketId);
+ // 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.
+ const day = body.position !== undefined
+ ? 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
+ // refetch the whole list; a plain append only needs the new day.
+ const event = body.position !== undefined ? 'day:reordered' : 'day:created';
+ this.days.broadcast(tripId, event, { day }, socketId);
return { day };
}
+ @Put('reorder')
+ reorder(
+ @CurrentUser() user: User,
+ @Param('tripId') tripId: string,
+ @Body() body: { orderedIds?: number[] },
+ @Headers('x-socket-id') socketId?: string,
+ ) {
+ const trip = this.requireTrip(tripId, user);
+ this.requireEdit(trip, user);
+ if (!Array.isArray(body.orderedIds)) {
+ throw new HttpException({ error: 'orderedIds must be an array' }, 400);
+ }
+ try {
+ this.days.reorder(tripId, body.orderedIds);
+ } catch (err) {
+ if (err instanceof DayReorderError) {
+ throw new HttpException({ error: err.message }, 400);
+ }
+ throw err;
+ }
+ this.days.broadcast(tripId, 'day:reordered', { orderedIds: body.orderedIds }, socketId);
+ return { success: true };
+ }
+
@Put(':id')
update(
@CurrentUser() user: User,
diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts
index 5890f61a..b40fac7c 100644
--- a/server/src/nest/days/days.service.ts
+++ b/server/src/nest/days/days.service.ts
@@ -39,6 +39,14 @@ export class DaysService {
return dayService.createDay(tripId, date, notes);
}
+ insert(tripId: string, position?: number) {
+ return dayService.insertDay(tripId, position);
+ }
+
+ reorder(tripId: string, orderedIds: number[]) {
+ return dayService.reorderDays(tripId, orderedIds);
+ }
+
update(id: string, current: Parameters[1], fields: { notes?: string; title?: string | null }) {
return dayService.updateDay(id, current, fields);
}
diff --git a/server/src/services/dayService.ts b/server/src/services/dayService.ts
index 46f295c7..d1d0d1e2 100644
--- a/server/src/services/dayService.ts
+++ b/server/src/services/dayService.ts
@@ -157,6 +157,220 @@ export function deleteDay(id: string | number) {
db.prepare('DELETE FROM days WHERE id = ?').run(id);
}
+// ---------------------------------------------------------------------------
+// Day reorder / insert (#589)
+//
+// Reordering keeps every day ROW stable (so assignments, notes, accommodations,
+// photos and multi-day reservation positions ride along by id) and only changes
+// each row's day_number — its position. On a dated trip the calendar dates stay
+// pinned to their slots (position i keeps the i-th date) and the day's content
+// moves across them. Because a booking's day is derived from the date part of
+// reservation_time, every booking on a day whose date changed gets that date
+// re-stamped onto the day's new date (time-of-day preserved), so day_id stays
+// consistent and the booking moves with its day.
+// ---------------------------------------------------------------------------
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+function addDays(date: string, n: number): string {
+ 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 {
+ const [fy, fm, fd] = from.split('-').map(Number);
+ const [ty, tm, td] = to.split('-').map(Number);
+ return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / MS_PER_DAY);
+}
+
+/** Replace the date part of an ISO-ish timestamp, keeping any time suffix. */
+function withDatePart(timestamp: string, date: string): string {
+ return date + (timestamp.length > 10 ? timestamp.slice(10) : '');
+}
+
+/**
+ * After day dates have been re-pinned, re-stamp the date of every booking on a
+ * moved day so reservation_time/reservation_end_time follow their day's new
+ * date (time-of-day preserved). Transport endpoints (flight legs) shift by the
+ * same per-booking day delta so multi-leg timing stays internally consistent.
+ */
+function restampReservationDates(
+ tripId: string | number,
+ oldDateById: Map,
+ newDateById: Map,
+): void {
+ const reservations = db.prepare(
+ 'SELECT id, day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE trip_id = ?'
+ ).all(tripId) as {
+ id: number; day_id: number | null; end_day_id: number | null;
+ reservation_time: string | null; reservation_end_time: string | null;
+ }[];
+
+ const setTime = db.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
+ const setEndTime = db.prepare('UPDATE reservations SET reservation_end_time = ? WHERE id = ?');
+ const endpoints = db.prepare('SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?');
+ const setEndpointDate = db.prepare('UPDATE reservation_endpoints SET local_date = ? WHERE id = ?');
+
+ for (const r of reservations) {
+ if (r.day_id != null && r.reservation_time) {
+ const oldDate = oldDateById.get(r.day_id);
+ const newDate = newDateById.get(r.day_id);
+ if (oldDate && newDate && oldDate !== newDate) {
+ setTime.run(withDatePart(r.reservation_time, newDate), r.id);
+ // Shift each transport leg's local_date by the same number of days.
+ const delta = dayDelta(oldDate, newDate);
+ if (delta !== 0) {
+ for (const ep of endpoints.all(r.id) as { id: number; local_date: string | null }[]) {
+ if (ep.local_date) setEndpointDate.run(addDays(ep.local_date, delta), ep.id);
+ }
+ }
+ }
+ }
+ if (r.end_day_id != null && r.reservation_end_time) {
+ const oldDate = oldDateById.get(r.end_day_id);
+ const newDate = newDateById.get(r.end_day_id);
+ if (oldDate && newDate && oldDate !== newDate) {
+ setEndTime.run(withDatePart(r.reservation_end_time, newDate), r.id);
+ }
+ }
+ }
+}
+
+/** A stay must not end before it begins after a reorder/insert. */
+function assertNoInvertedAccommodation(tripId: string | number): void {
+ const spans = db.prepare(`
+ SELECT a.id, s.day_number AS start_no, e.day_number AS end_no
+ FROM day_accommodations a
+ JOIN days s ON a.start_day_id = s.id
+ JOIN days e ON a.end_day_id = e.id
+ WHERE a.trip_id = ?
+ `).all(tripId) as { id: number; start_no: number; end_no: number }[];
+ for (const span of spans) {
+ if (span.start_no > span.end_no) {
+ throw new DayReorderError('This move would make an accommodation end before it starts.');
+ }
+ }
+}
+
+/** Thrown for invalid reorder/insert requests; mapped to HTTP 400 by the controller. */
+export class DayReorderError extends Error {}
+
+/**
+ * Reorder whole days. `orderedIds` is the desired full sequence of this trip's
+ * day ids (a permutation of the current ids).
+ */
+export function reorderDays(tripId: string | number, orderedIds: number[]) {
+ const rows = db.prepare(
+ 'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number'
+ ).all(tripId) as { id: number; day_number: number; date: string | null }[];
+
+ const existingIds = new Set(rows.map(r => r.id));
+ if (orderedIds.length !== rows.length || !orderedIds.every(id => existingIds.has(id))) {
+ throw new DayReorderError('orderedIds must be a permutation of the trip day ids.');
+ }
+
+ const oldDateById = new Map(rows.map(r => [r.id, r.date]));
+ // Dates stay pinned to slots: position i keeps the i-th date (ascending).
+ const sortedDates = rows.map(r => r.date).filter((d): d is string => !!d).sort();
+ const isDated = sortedDates.length > 0;
+
+ const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
+ const setDayNumberAndDate = db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
+
+ db.exec('BEGIN');
+ try {
+ // Two-phase renumber to dodge UNIQUE(trip_id, day_number) collisions.
+ orderedIds.forEach((id, i) => setDayNumber.run(-(i + 1), id));
+ const newDateById = new Map();
+ orderedIds.forEach((id, i) => {
+ const date = isDated ? (sortedDates[i] ?? null) : null;
+ setDayNumberAndDate.run(i + 1, date, id);
+ newDateById.set(id, date);
+ });
+
+ if (isDated) restampReservationDates(tripId, oldDateById, newDateById);
+ assertNoInvertedAccommodation(tripId);
+
+ db.exec('COMMIT');
+ } catch (e) {
+ db.exec('ROLLBACK');
+ throw e;
+ }
+
+ return listDays(tripId);
+}
+
+/**
+ * Insert a new empty day at a 1-based position (default: append at the end).
+ * On a dated trip the trip gains one calendar day: dates re-pin so the slots
+ * stay contiguous, the trip's end_date extends by one day, and bookings on
+ * shifted days have their dates re-stamped (same rules as reorderDays).
+ */
+export function insertDay(tripId: string | number, position?: number) {
+ const rows = db.prepare(
+ 'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number'
+ ).all(tripId) as { id: number; day_number: number; date: string | null }[];
+ const n = rows.length;
+ const pos = Math.min(Math.max(position ?? n + 1, 1), n + 1);
+ const datedRows = rows.filter(r => r.date) as { id: number; day_number: number; date: string }[];
+ const isDated = datedRows.length > 0;
+
+ const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
+
+ if (!isDated) {
+ db.exec('BEGIN');
+ try {
+ const toShift = rows.filter(r => r.day_number >= pos);
+ toShift.forEach(r => setDayNumber.run(-r.day_number, r.id));
+ const result = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(tripId, pos);
+ toShift.forEach(r => setDayNumber.run(r.day_number + 1, r.id));
+ db.exec('COMMIT');
+ const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as Day;
+ return { ...day, assignments: [], notes_items: [] };
+ } catch (e) {
+ db.exec('ROLLBACK');
+ throw e;
+ }
+ }
+
+ // Dated trip: rebuild N+1 contiguous dates from the earliest date.
+ const start = datedRows.map(r => r.date).sort()[0];
+ const dates = Array.from({ length: n + 1 }, (_, i) => addDays(start, i));
+ const oldDateById = new Map(rows.map(r => [r.id, r.date]));
+ const setDayNumberAndDate = db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
+
+ db.exec('BEGIN');
+ try {
+ rows.forEach((r, i) => setDayNumber.run(-(i + 1), r.id));
+ const result = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, pos, dates[pos - 1]);
+ const newId = Number(result.lastInsertRowid);
+
+ const orderedIds = rows.map(r => r.id);
+ orderedIds.splice(pos - 1, 0, newId);
+ const newDateById = new Map();
+ orderedIds.forEach((id, i) => {
+ setDayNumberAndDate.run(i + 1, dates[i], id);
+ newDateById.set(id, dates[i]);
+ });
+
+ restampReservationDates(tripId, oldDateById, newDateById);
+ assertNoInvertedAccommodation(tripId);
+ db.prepare('UPDATE trips SET end_date = ? WHERE id = ?').run(dates[dates.length - 1], tripId);
+
+ db.exec('COMMIT');
+ const day = db.prepare('SELECT * FROM days WHERE id = ?').get(newId) as Day;
+ return { ...day, assignments: [], notes_items: [] };
+ } catch (e) {
+ db.exec('ROLLBACK');
+ throw e;
+ }
+}
+
// ---------------------------------------------------------------------------
// Accommodation helpers
// ---------------------------------------------------------------------------
diff --git a/server/tests/integration/dayReorder.test.ts b/server/tests/integration/dayReorder.test.ts
new file mode 100644
index 00000000..bf7ebb44
--- /dev/null
+++ b/server/tests/integration/dayReorder.test.ts
@@ -0,0 +1,134 @@
+/**
+ * Day reorder + insert integration tests (#589) — exercises the real
+ * dayService against the real schema. Covers: position renumber, dates pinned
+ * to slots while content rides along by id, booking-date re-stamp, permutation
+ * validation, the accommodation-inversion guard, and insert (dated + dateless).
+ */
+import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
+
+const { testDb, dbMock } = vi.hoisted(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const Database = require('better-sqlite3');
+ const db = new Database(':memory:');
+ db.exec('PRAGMA journal_mode = WAL');
+ db.exec('PRAGMA foreign_keys = ON');
+ db.exec('PRAGMA busy_timeout = 5000');
+ return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: vi.fn() } };
+});
+
+vi.mock('../../src/db/database', () => dbMock);
+vi.mock('../../src/config', () => ({
+ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
+ ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
+ updateJwtSecret: () => {},
+ DEFAULT_LANGUAGE: 'en',
+}));
+
+import { createTables } from '../../src/db/schema';
+import { runMigrations } from '../../src/db/migrations';
+import { resetTestDb } from '../helpers/test-db';
+import { createUser, createTrip, createPlace, createDay, createDayAssignment, createReservation, createDayAccommodation } from '../helpers/factories';
+import { reorderDays, insertDay, DayReorderError } from '../../src/services/dayService';
+
+let userId: number;
+
+beforeAll(() => {
+ createTables(testDb);
+ runMigrations(testDb);
+});
+
+beforeEach(() => {
+ resetTestDb(testDb);
+ userId = createUser(testDb).user.id;
+});
+
+afterAll(() => testDb.close());
+
+const orderedDays = (tripId: number) =>
+ testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as
+ { id: number; day_number: number; date: string | null }[];
+
+describe('reorderDays', () => {
+ it('permutes positions, pins dates to slots, and content rides along by id', () => {
+ const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
+ const [d1, d2, d3] = orderedDays(trip.id);
+ const place = createPlace(testDb, trip.id);
+ createDayAssignment(testDb, d2.id, place.id); // place sits on day 2
+
+ // Move day 2 to the front: [d2, d1, d3]
+ reorderDays(trip.id, [d2.id, d1.id, d3.id]);
+
+ const after = orderedDays(trip.id);
+ expect(after.map(d => d.id)).toEqual([d2.id, d1.id, d3.id]);
+ // Dates stay pinned to their calendar slots
+ expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02', '2026-03-03']);
+ // The place rides along with its day row (still attached to d2.id, now at slot 1)
+ const onD2 = testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(d2.id);
+ expect(onD2).toHaveLength(1);
+ });
+
+ it('re-stamps a booking\'s date onto its day\'s new date, keeping the time', () => {
+ const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
+ const [d1, d2, d3] = orderedDays(trip.id);
+ const res = createReservation(testDb, trip.id, { day_id: d2.id, type: 'restaurant' });
+ testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2026-03-02T19:00', res.id);
+
+ reorderDays(trip.id, [d2.id, d1.id, d3.id]); // d2 moves to the 2026-03-01 slot
+
+ const r = testDb.prepare('SELECT reservation_time FROM reservations WHERE id = ?').get(res.id) as { reservation_time: string };
+ expect(r.reservation_time).toBe('2026-03-01T19:00');
+ });
+
+ it('rejects an orderedIds list that is not a permutation of the trip days', () => {
+ const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
+ const [d1, d2] = orderedDays(trip.id);
+ expect(() => reorderDays(trip.id, [d1.id, d2.id])).toThrow(DayReorderError);
+ });
+
+ it('blocks a move that would make an accommodation end before it starts, and rolls back', () => {
+ const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
+ const [d1, d2, d3] = orderedDays(trip.id);
+ const place = createPlace(testDb, trip.id);
+ createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id); // stay spans day 1 -> day 2
+
+ // Put the start day (d1) after the end day (d2): [d2, d3, d1]
+ expect(() => reorderDays(trip.id, [d2.id, d3.id, d1.id])).toThrow(DayReorderError);
+
+ // Transaction rolled back: original order intact
+ expect(orderedDays(trip.id).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
+ });
+});
+
+describe('insertDay', () => {
+ it('inserts an empty day at a position on a dateless trip and shifts the rest', () => {
+ const trip = createTrip(testDb, userId);
+ const d1 = createDay(testDb, trip.id);
+ const d2 = createDay(testDb, trip.id);
+ const d3 = createDay(testDb, trip.id);
+
+ const created = insertDay(trip.id, 1);
+
+ const after = orderedDays(trip.id);
+ expect(after).toHaveLength(4);
+ expect(after[0].id).toBe(created.id);
+ expect(after[0].date).toBeNull();
+ expect(after.slice(1).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
+ });
+
+ it('inserts at the front of a dated trip: dates stay contiguous and the trip extends', () => {
+ const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
+ const [d1, d2, d3] = orderedDays(trip.id);
+
+ const created = insertDay(trip.id, 1);
+
+ const after = orderedDays(trip.id);
+ expect(after).toHaveLength(4);
+ expect(after[0].id).toBe(created.id);
+ expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04']);
+ // Old content shifted down a slot
+ expect(after.slice(1).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
+ // Trip range extended by one day
+ const t = testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id) as { end_date: string };
+ expect(t.end_date).toBe('2026-03-04');
+ });
+});
diff --git a/shared/src/day/day.schema.ts b/shared/src/day/day.schema.ts
index 2b4ab749..2712af7e 100644
--- a/shared/src/day/day.schema.ts
+++ b/shared/src/day/day.schema.ts
@@ -49,9 +49,17 @@ export type Day = z.infer;
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(),
});
export type DayCreateRequest = z.infer;
+/** Reorder whole days: the desired full sequence of this trip's day ids. */
+export const dayReorderRequestSchema = z.object({
+ orderedIds: z.array(z.number()),
+});
+export type DayReorderRequest = z.infer;
+
export const dayUpdateRequestSchema = z.object({
notes: z.string().optional(),
title: z.string().nullable().optional(),
diff --git a/shared/src/i18n/ar/dayplan.ts b/shared/src/i18n/ar/dayplan.ts
index 061a0dc0..344fe5fc 100644
--- a/shared/src/i18n/ar/dayplan.ts
+++ b/shared/src/i18n/ar/dayplan.ts
@@ -45,5 +45,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.allAssigned': 'All places assigned', // en-fallback
'dayplan.mobile.noMatch': 'No match', // en-fallback
'dayplan.mobile.createNew': 'Create new place', // en-fallback
+ 'dayplan.reorderDays': 'إعادة ترتيب الأيام',
+ 'dayplan.reorderTitle': 'إعادة ترتيب الأيام',
+ 'dayplan.reorderHint': 'تنتقل أماكن اليوم وملاحظاته وحجوزاته معه.',
+ 'dayplan.addDay': 'إضافة يوم',
+ 'dayplan.moveUp': 'تحريك لأعلى',
+ 'dayplan.moveDown': 'تحريك لأسفل',
+ 'dayplan.reorderUndo': 'إعادة ترتيب الأيام',
+ 'dayplan.reorderError': 'تعذّر إعادة ترتيب الأيام',
+ 'dayplan.addDayError': 'تعذّر إضافة يوم',
};
export default dayplan;
diff --git a/shared/src/i18n/br/dayplan.ts b/shared/src/i18n/br/dayplan.ts
index 53ee5afd..e641ea32 100644
--- a/shared/src/i18n/br/dayplan.ts
+++ b/shared/src/i18n/br/dayplan.ts
@@ -48,5 +48,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': 'Criar novo lugar',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': 'Reordenar dias',
+ 'dayplan.reorderTitle': 'Reordenar dias',
+ 'dayplan.reorderHint': 'Os lugares, notas e reservas de um dia se movem junto com ele.',
+ 'dayplan.addDay': 'Adicionar dia',
+ 'dayplan.moveUp': 'Mover para cima',
+ 'dayplan.moveDown': 'Mover para baixo',
+ 'dayplan.reorderUndo': 'Reordenar dias',
+ 'dayplan.reorderError': 'Falha ao reordenar os dias',
+ 'dayplan.addDayError': 'Falha ao adicionar o dia',
};
export default dayplan;
diff --git a/shared/src/i18n/cs/dayplan.ts b/shared/src/i18n/cs/dayplan.ts
index b13a5177..d3c8bb72 100644
--- a/shared/src/i18n/cs/dayplan.ts
+++ b/shared/src/i18n/cs/dayplan.ts
@@ -48,5 +48,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': 'Vytvořit nové místo',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': 'Změnit pořadí dnů',
+ 'dayplan.reorderTitle': 'Změnit pořadí dnů',
+ 'dayplan.reorderHint': 'Místa, poznámky a rezervace daného dne se přesunou spolu s ním.',
+ 'dayplan.addDay': 'Přidat den',
+ 'dayplan.moveUp': 'Posunout nahoru',
+ 'dayplan.moveDown': 'Posunout dolů',
+ 'dayplan.reorderUndo': 'Změnit pořadí dnů',
+ 'dayplan.reorderError': 'Nepodařilo se změnit pořadí dnů',
+ 'dayplan.addDayError': 'Nepodařilo se přidat den',
};
export default dayplan;
diff --git a/shared/src/i18n/de/dayplan.ts b/shared/src/i18n/de/dayplan.ts
index d84948cc..eeb49194 100644
--- a/shared/src/i18n/de/dayplan.ts
+++ b/shared/src/i18n/de/dayplan.ts
@@ -47,5 +47,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.allAssigned': 'Alle Orte zugeordnet',
'dayplan.mobile.noMatch': 'Kein Treffer',
'dayplan.mobile.createNew': 'Neuen Ort erstellen',
+ 'dayplan.reorderDays': 'Tage neu anordnen',
+ 'dayplan.reorderTitle': 'Tage neu anordnen',
+ 'dayplan.reorderHint': 'Orte, Notizen und Buchungen eines Tages werden mitverschoben.',
+ 'dayplan.addDay': 'Tag hinzufügen',
+ 'dayplan.moveUp': 'Nach oben',
+ 'dayplan.moveDown': 'Nach unten',
+ 'dayplan.reorderUndo': 'Tage neu anordnen',
+ 'dayplan.reorderError': 'Tage konnten nicht neu angeordnet werden',
+ 'dayplan.addDayError': 'Tag konnte nicht hinzugefügt werden',
};
export default dayplan;
diff --git a/shared/src/i18n/en/dayplan.ts b/shared/src/i18n/en/dayplan.ts
index 28e537ab..caf7f13e 100644
--- a/shared/src/i18n/en/dayplan.ts
+++ b/shared/src/i18n/en/dayplan.ts
@@ -47,5 +47,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.allAssigned': 'All places assigned',
'dayplan.mobile.noMatch': 'No match',
'dayplan.mobile.createNew': 'Create new place',
+ 'dayplan.reorderDays': 'Reorder days',
+ 'dayplan.reorderTitle': 'Reorder days',
+ 'dayplan.reorderHint': "A day's places, notes and bookings move with it.",
+ 'dayplan.addDay': 'Add day',
+ 'dayplan.moveUp': 'Move up',
+ 'dayplan.moveDown': 'Move down',
+ 'dayplan.reorderUndo': 'Reorder days',
+ 'dayplan.reorderError': 'Failed to reorder days',
+ 'dayplan.addDayError': 'Failed to add day',
};
export default dayplan;
diff --git a/shared/src/i18n/es/dayplan.ts b/shared/src/i18n/es/dayplan.ts
index e7f4d998..45d385f2 100644
--- a/shared/src/i18n/es/dayplan.ts
+++ b/shared/src/i18n/es/dayplan.ts
@@ -47,5 +47,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': 'Crear nuevo lugar',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': 'Reordenar días',
+ 'dayplan.reorderTitle': 'Reordenar días',
+ 'dayplan.reorderHint': 'Los lugares, las notas y las reservas de un día se mueven con él.',
+ 'dayplan.addDay': 'Añadir día',
+ 'dayplan.moveUp': 'Subir',
+ 'dayplan.moveDown': 'Bajar',
+ 'dayplan.reorderUndo': 'Reordenar días',
+ 'dayplan.reorderError': 'No se pudieron reordenar los días',
+ 'dayplan.addDayError': 'No se pudo añadir el día',
};
export default dayplan;
diff --git a/shared/src/i18n/fr/dayplan.ts b/shared/src/i18n/fr/dayplan.ts
index 1e77b5ab..167b834c 100644
--- a/shared/src/i18n/fr/dayplan.ts
+++ b/shared/src/i18n/fr/dayplan.ts
@@ -49,5 +49,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': 'Créer un nouveau lieu',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': 'Réorganiser les jours',
+ 'dayplan.reorderTitle': 'Réorganiser les jours',
+ 'dayplan.reorderHint': 'Les lieux, notes et réservations d\'un jour le suivent.',
+ 'dayplan.addDay': 'Ajouter un jour',
+ 'dayplan.moveUp': 'Monter',
+ 'dayplan.moveDown': 'Descendre',
+ 'dayplan.reorderUndo': 'Réorganiser les jours',
+ 'dayplan.reorderError': 'Échec de la réorganisation des jours',
+ 'dayplan.addDayError': 'Échec de l\'ajout du jour',
};
export default dayplan;
diff --git a/shared/src/i18n/gr/dayplan.ts b/shared/src/i18n/gr/dayplan.ts
index 58ef6253..586b8e87 100644
--- a/shared/src/i18n/gr/dayplan.ts
+++ b/shared/src/i18n/gr/dayplan.ts
@@ -49,5 +49,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.allAssigned': 'Όλες οι τοποθεσίες έχουν ανατεθεί',
'dayplan.mobile.noMatch': 'Καμία αντιστοιχία',
'dayplan.mobile.createNew': 'Δημιουργία νέας τοποθεσίας',
+ 'dayplan.reorderDays': 'Αναδιάταξη ημερών',
+ 'dayplan.reorderTitle': 'Αναδιάταξη ημερών',
+ 'dayplan.reorderHint': 'Τα μέρη, οι σημειώσεις και οι κρατήσεις μιας ημέρας μετακινούνται μαζί της.',
+ 'dayplan.addDay': 'Προσθήκη ημέρας',
+ 'dayplan.moveUp': 'Μετακίνηση πάνω',
+ 'dayplan.moveDown': 'Μετακίνηση κάτω',
+ 'dayplan.reorderUndo': 'Αναδιάταξη ημερών',
+ 'dayplan.reorderError': 'Η αναδιάταξη των ημερών απέτυχε',
+ 'dayplan.addDayError': 'Η προσθήκη ημέρας απέτυχε',
};
export default dayplan;
diff --git a/shared/src/i18n/hu/dayplan.ts b/shared/src/i18n/hu/dayplan.ts
index 2a407180..b13e4047 100644
--- a/shared/src/i18n/hu/dayplan.ts
+++ b/shared/src/i18n/hu/dayplan.ts
@@ -48,5 +48,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': 'Új helyszín létrehozása',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': 'Napok átrendezése',
+ 'dayplan.reorderTitle': 'Napok átrendezése',
+ 'dayplan.reorderHint': 'A nap helyei, jegyzetei és foglalásai együtt mozognak vele.',
+ 'dayplan.addDay': 'Nap hozzáadása',
+ 'dayplan.moveUp': 'Mozgatás felfelé',
+ 'dayplan.moveDown': 'Mozgatás lefelé',
+ 'dayplan.reorderUndo': 'Napok átrendezése',
+ 'dayplan.reorderError': 'Nem sikerült átrendezni a napokat',
+ 'dayplan.addDayError': 'Nem sikerült napot hozzáadni',
};
export default dayplan;
diff --git a/shared/src/i18n/id/dayplan.ts b/shared/src/i18n/id/dayplan.ts
index 88905d7b..c4576bb6 100644
--- a/shared/src/i18n/id/dayplan.ts
+++ b/shared/src/i18n/id/dayplan.ts
@@ -48,5 +48,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': 'Buat tempat baru',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': 'Atur ulang hari',
+ 'dayplan.reorderTitle': 'Atur ulang hari',
+ 'dayplan.reorderHint': 'Tempat, catatan, dan pesanan pada suatu hari ikut berpindah.',
+ 'dayplan.addDay': 'Tambah hari',
+ 'dayplan.moveUp': 'Pindah ke atas',
+ 'dayplan.moveDown': 'Pindah ke bawah',
+ 'dayplan.reorderUndo': 'Atur ulang hari',
+ 'dayplan.reorderError': 'Gagal mengatur ulang hari',
+ 'dayplan.addDayError': 'Gagal menambah hari',
};
export default dayplan;
diff --git a/shared/src/i18n/it/dayplan.ts b/shared/src/i18n/it/dayplan.ts
index f5625d61..4e95fe79 100644
--- a/shared/src/i18n/it/dayplan.ts
+++ b/shared/src/i18n/it/dayplan.ts
@@ -49,5 +49,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': 'Crea nuovo luogo',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': 'Riordina i giorni',
+ 'dayplan.reorderTitle': 'Riordina i giorni',
+ 'dayplan.reorderHint': 'I luoghi, le note e le prenotazioni di un giorno si spostano insieme a esso.',
+ 'dayplan.addDay': 'Aggiungi giorno',
+ 'dayplan.moveUp': 'Sposta su',
+ 'dayplan.moveDown': 'Sposta giù',
+ 'dayplan.reorderUndo': 'Riordina i giorni',
+ 'dayplan.reorderError': 'Riordino dei giorni non riuscito',
+ 'dayplan.addDayError': 'Aggiunta del giorno non riuscita',
};
export default dayplan;
diff --git a/shared/src/i18n/ja/dayplan.ts b/shared/src/i18n/ja/dayplan.ts
index 64f49fac..1cb8d3f0 100644
--- a/shared/src/i18n/ja/dayplan.ts
+++ b/shared/src/i18n/ja/dayplan.ts
@@ -42,5 +42,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.allAssigned': 'すべて割り当て済み',
'dayplan.mobile.noMatch': '一致なし',
'dayplan.mobile.createNew': '新しい場所を作成',
+ 'dayplan.reorderDays': '日付を並べ替え',
+ 'dayplan.reorderTitle': '日付を並べ替え',
+ 'dayplan.reorderHint': 'その日のスポット、メモ、予約も一緒に移動します。',
+ 'dayplan.addDay': '日付を追加',
+ 'dayplan.moveUp': '上へ移動',
+ 'dayplan.moveDown': '下へ移動',
+ 'dayplan.reorderUndo': '日付を並べ替え',
+ 'dayplan.reorderError': '日付の並べ替えに失敗しました',
+ 'dayplan.addDayError': '日付の追加に失敗しました',
};
export default dayplan;
diff --git a/shared/src/i18n/ko/dayplan.ts b/shared/src/i18n/ko/dayplan.ts
index b2095b22..2f9f78cb 100644
--- a/shared/src/i18n/ko/dayplan.ts
+++ b/shared/src/i18n/ko/dayplan.ts
@@ -45,5 +45,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.allAssigned': '모든 장소가 배정되었습니다',
'dayplan.mobile.noMatch': '일치 없음',
'dayplan.mobile.createNew': '새 장소 만들기',
+ 'dayplan.reorderDays': '날짜 순서 변경',
+ 'dayplan.reorderTitle': '날짜 순서 변경',
+ 'dayplan.reorderHint': '해당 날짜의 장소, 메모, 예약이 함께 이동합니다.',
+ 'dayplan.addDay': '날짜 추가',
+ 'dayplan.moveUp': '위로 이동',
+ 'dayplan.moveDown': '아래로 이동',
+ 'dayplan.reorderUndo': '날짜 순서 변경',
+ 'dayplan.reorderError': '날짜 순서를 변경하지 못했습니다',
+ 'dayplan.addDayError': '날짜를 추가하지 못했습니다',
};
export default dayplan;
diff --git a/shared/src/i18n/nl/dayplan.ts b/shared/src/i18n/nl/dayplan.ts
index 80ebfec9..9070dc9d 100644
--- a/shared/src/i18n/nl/dayplan.ts
+++ b/shared/src/i18n/nl/dayplan.ts
@@ -48,5 +48,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': 'Nieuwe plaats aanmaken',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': 'Dagen herordenen',
+ 'dayplan.reorderTitle': 'Dagen herordenen',
+ 'dayplan.reorderHint': 'De plaatsen, notities en boekingen van een dag gaan mee.',
+ 'dayplan.addDay': 'Dag toevoegen',
+ 'dayplan.moveUp': 'Omhoog',
+ 'dayplan.moveDown': 'Omlaag',
+ 'dayplan.reorderUndo': 'Dagen herordenen',
+ 'dayplan.reorderError': 'Dagen herordenen mislukt',
+ 'dayplan.addDayError': 'Dag toevoegen mislukt',
};
export default dayplan;
diff --git a/shared/src/i18n/pl/dayplan.ts b/shared/src/i18n/pl/dayplan.ts
index 96f67d2e..0d0f6c2e 100644
--- a/shared/src/i18n/pl/dayplan.ts
+++ b/shared/src/i18n/pl/dayplan.ts
@@ -48,5 +48,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': 'Utwórz nowe miejsce',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': 'Zmień kolejność dni',
+ 'dayplan.reorderTitle': 'Zmień kolejność dni',
+ 'dayplan.reorderHint': 'Miejsca, notatki i rezerwacje danego dnia przenoszą się razem z nim.',
+ 'dayplan.addDay': 'Dodaj dzień',
+ 'dayplan.moveUp': 'Przenieś w górę',
+ 'dayplan.moveDown': 'Przenieś w dół',
+ 'dayplan.reorderUndo': 'Zmień kolejność dni',
+ 'dayplan.reorderError': 'Nie udało się zmienić kolejności dni',
+ 'dayplan.addDayError': 'Nie udało się dodać dnia',
};
export default dayplan;
diff --git a/shared/src/i18n/ru/dayplan.ts b/shared/src/i18n/ru/dayplan.ts
index b64d1308..00748404 100644
--- a/shared/src/i18n/ru/dayplan.ts
+++ b/shared/src/i18n/ru/dayplan.ts
@@ -48,5 +48,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': 'Создать новое место',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': 'Изменить порядок дней',
+ 'dayplan.reorderTitle': 'Изменить порядок дней',
+ 'dayplan.reorderHint': 'Места, заметки и бронирования дня перемещаются вместе с ним.',
+ 'dayplan.addDay': 'Добавить день',
+ 'dayplan.moveUp': 'Вверх',
+ 'dayplan.moveDown': 'Вниз',
+ 'dayplan.reorderUndo': 'Изменить порядок дней',
+ 'dayplan.reorderError': 'Не удалось изменить порядок дней',
+ 'dayplan.addDayError': 'Не удалось добавить день',
};
export default dayplan;
diff --git a/shared/src/i18n/tr/dayplan.ts b/shared/src/i18n/tr/dayplan.ts
index 4b108330..ba61734d 100644
--- a/shared/src/i18n/tr/dayplan.ts
+++ b/shared/src/i18n/tr/dayplan.ts
@@ -47,5 +47,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.allAssigned': 'Tüm yerler atandı',
'dayplan.mobile.noMatch': 'Eşleşme yok',
'dayplan.mobile.createNew': 'Yeni yer oluştur',
+ 'dayplan.reorderDays': 'Günleri yeniden sırala',
+ 'dayplan.reorderTitle': 'Günleri yeniden sırala',
+ 'dayplan.reorderHint': 'Bir günün yerleri, notları ve rezervasyonları onunla birlikte taşınır.',
+ 'dayplan.addDay': 'Gün ekle',
+ 'dayplan.moveUp': 'Yukarı taşı',
+ 'dayplan.moveDown': 'Aşağı taşı',
+ 'dayplan.reorderUndo': 'Günleri yeniden sırala',
+ 'dayplan.reorderError': 'Günler yeniden sıralanamadı',
+ 'dayplan.addDayError': 'Gün eklenemedi',
};
export default dayplan;
diff --git a/shared/src/i18n/uk/dayplan.ts b/shared/src/i18n/uk/dayplan.ts
index c5ef0afa..a57ff455 100644
--- a/shared/src/i18n/uk/dayplan.ts
+++ b/shared/src/i18n/uk/dayplan.ts
@@ -47,5 +47,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.allAssigned': 'Усі місця розподілені',
'dayplan.mobile.noMatch': 'Немає збігів',
'dayplan.mobile.createNew': 'Створити нове місце',
+ 'dayplan.reorderDays': 'Змінити порядок днів',
+ 'dayplan.reorderTitle': 'Змінити порядок днів',
+ 'dayplan.reorderHint': 'Місця, нотатки та бронювання дня переміщуються разом із ним.',
+ 'dayplan.addDay': 'Додати день',
+ 'dayplan.moveUp': 'Перемістити вгору',
+ 'dayplan.moveDown': 'Перемістити вниз',
+ 'dayplan.reorderUndo': 'Змінити порядок днів',
+ 'dayplan.reorderError': 'Не вдалося змінити порядок днів',
+ 'dayplan.addDayError': 'Не вдалося додати день',
};
export default dayplan;
diff --git a/shared/src/i18n/zh-TW/dayplan.ts b/shared/src/i18n/zh-TW/dayplan.ts
index fa5ecce9..f986e845 100644
--- a/shared/src/i18n/zh-TW/dayplan.ts
+++ b/shared/src/i18n/zh-TW/dayplan.ts
@@ -42,5 +42,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': '建立新地點',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': '重新排序日期',
+ 'dayplan.reorderTitle': '重新排序日期',
+ 'dayplan.reorderHint': '該日的地點、筆記和預訂都會一併移動。',
+ 'dayplan.addDay': '新增日期',
+ 'dayplan.moveUp': '上移',
+ 'dayplan.moveDown': '下移',
+ 'dayplan.reorderUndo': '重新排序日期',
+ 'dayplan.reorderError': '重新排序日期失敗',
+ 'dayplan.addDayError': '新增日期失敗',
};
export default dayplan;
diff --git a/shared/src/i18n/zh/dayplan.ts b/shared/src/i18n/zh/dayplan.ts
index d09d5a1a..06d5a7a5 100644
--- a/shared/src/i18n/zh/dayplan.ts
+++ b/shared/src/i18n/zh/dayplan.ts
@@ -42,5 +42,14 @@ const dayplan: TranslationStrings = {
'dayplan.mobile.createNew': '创建新地点',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
+ 'dayplan.reorderDays': '调整日期顺序',
+ 'dayplan.reorderTitle': '调整日期顺序',
+ 'dayplan.reorderHint': '该天的地点、备注和预订会随之一起移动。',
+ 'dayplan.addDay': '添加一天',
+ 'dayplan.moveUp': '上移',
+ 'dayplan.moveDown': '下移',
+ 'dayplan.reorderUndo': '调整日期顺序',
+ 'dayplan.reorderError': '调整日期顺序失败',
+ 'dayplan.addDayError': '添加一天失败',
};
export default dayplan;