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;