mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
* 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:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user