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
This commit is contained in:
Maurice
2026-06-12 00:17:49 +02:00
committed by GitHub
parent 1378c95078
commit f46cc8a98e
34 changed files with 872 additions and 9 deletions
+2 -1
View File
@@ -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 = {
@@ -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 */}
@@ -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 (
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
@@ -197,6 +204,39 @@ export function DayPlanSidebarToolbar({
)}
</div>
)}
{canEditDays && onReorderDays && onAddDay && days.length > 0 && (
<div style={{ position: 'relative', flexShrink: 0 }}>
<Tooltip label={t('dayplan.reorderDays')} placement="bottom">
<button
onClick={() => setReorderOpen(v => !v)}
aria-label={t('dayplan.reorderDays')}
aria-pressed={reorderOpen}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 30, height: 30, borderRadius: 8,
border: '1px solid var(--border-primary)',
background: reorderOpen ? 'var(--bg-hover)' : 'none',
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
}}
onMouseEnter={e => { if (!reorderOpen) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!reorderOpen) e.currentTarget.style.background = 'transparent' }}
>
<ArrowUpDown size={14} strokeWidth={2} />
</button>
</Tooltip>
{reorderOpen && (
<DayReorderPopup
days={days}
t={t}
locale={locale}
onReorder={onReorderDays}
onAddDay={() => onAddDay()}
onClose={() => setReorderOpen(false)}
/>
)}
</div>
)}
</div>
</div>
)
@@ -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, any>) => 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<number | null>(null)
const [overIndex, setOverIndex] = useState<number | null>(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 */}
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 250 }} />
<div
onClick={e => 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',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '11px 12px 8px' }}>
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{t('dayplan.reorderTitle')}</span>
<button
onClick={onAddDay}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 9px',
borderRadius: 7, border: 'none', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Plus size={13} strokeWidth={2} />
{t('dayplan.addDay')}
</button>
</div>
<div style={{ padding: '0 12px 8px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.35 }}>
{t('dayplan.reorderHint')}
</div>
<div className="scroll-container" style={{ overflowY: 'auto', padding: '0 8px 8px', minHeight: 0 }}>
{ordered.map((day, index) => (
<div
key={day.id}
draggable
onDragStart={() => 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,
}}
>
<GripVertical size={14} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{
flexShrink: 0, width: 22, height: 22, borderRadius: '50%',
background: 'var(--bg-hover)', color: 'var(--text-muted)',
display: 'grid', placeItems: 'center', fontSize: 10.5, fontWeight: 700,
}}>
{index + 1}
</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label(day, index)}
</span>
<button
onClick={() => move(index, index - 1)}
disabled={index === 0}
aria-label={t('dayplan.moveUp')}
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
>
<ArrowUp size={13} strokeWidth={2} />
</button>
<button
onClick={() => move(index, index + 1)}
disabled={index === ordered.length - 1}
aria-label={t('dayplan.moveDown')}
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
>
<ArrowDown size={13} strokeWidth={2} />
</button>
</div>
))}
</div>
</div>
</>
)
}
+4 -2
View File
@@ -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 {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { 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 }} />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} 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 }} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { 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 }} />
}
</div>
+18 -1
View File
@@ -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<string, string | number | null> & { 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,
+58
View File
@@ -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<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface DaysSlice {
reorderDays: (tripId: number | string, orderedIds: number[]) => Promise<void>
insertDay: (tripId: number | string, position?: number) => Promise<Day | undefined>
}
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<typeof d> => 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'))
}
},
})
@@ -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<string, unknown>, get())
}
+4
View File
@@ -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<TripStoreState>((set, get) => ({
...createPlacesSlice(set, get),
...createAssignmentsSlice(set, get),
...createDaysSlice(set, get),
...createDayNotesSlice(set, get),
...createPackingSlice(set, get),
...createTodoSlice(set, get),
+35 -3
View File
@@ -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,
+8
View File
@@ -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<typeof dayService.updateDay>[1], fields: { notes?: string; title?: string | null }) {
return dayService.updateDay(id, current, fields);
}
+214
View File
@@ -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<number, string | null>,
newDateById: Map<number, string | null>,
): 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<number, string | null>();
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<number, string | null>();
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
// ---------------------------------------------------------------------------
+134
View File
@@ -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');
});
});
+8
View File
@@ -49,9 +49,17 @@ export type Day = z.infer<typeof daySchema>;
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<typeof dayCreateRequestSchema>;
/** 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<typeof dayReorderRequestSchema>;
export const dayUpdateRequestSchema = z.object({
notes: z.string().optional(),
title: z.string().nullable().optional(),
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;