From 03757ed0af8c24eb05a395c2a86c642fa78a8f67 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 5 Apr 2026 23:02:42 +0200 Subject: [PATCH] fix(dayplan): per-day transport positions for multi-day reservations Reordering places on one day of a multi-day reservation no longer affects the order on other days. Transport positions are now stored per-day in a new reservation_day_positions table instead of a single global day_plan_position on the reservation. --- client/src/api/client.ts | 2 +- .../src/components/Planner/DayPlanSidebar.tsx | 18 ++++-- server/src/db/migrations.ts | 21 +++++++ server/src/routes/reservations.ts | 5 +- server/src/services/reservationService.ts | 61 ++++++++++++++++--- 5 files changed, 89 insertions(+), 18 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index eb3a236..237d3e6 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -248,7 +248,7 @@ export const reservationsApi = { create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), - updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data), + updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data), } export const weatherApi = { diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 58e8a75..9a575be 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -366,9 +366,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const timed = timedTransports[ti] const minutes = timed.minutes - // Use persisted position if available - if (timed.data.day_plan_position != null) { - result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data }) + // Use per-day position if available, fallback to global position + const dayObj = days.find(d => d.id === dayId) + const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)] + const effectivePos = perDayPos ?? timed.data.day_plan_position + if (effectivePos != null) { + result.push({ type: timed.type, sortKey: effectivePos, data: timed.data }) continue } @@ -500,10 +503,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (transportUpdates.length) { for (const tu of transportUpdates) { const res = reservations.find(r => r.id === tu.id) - if (res) res.day_plan_position = tu.day_plan_position + if (res) { + res.day_plan_position = tu.day_plan_position + // Update per-day position for multi-day reservations + if (!res.day_positions) res.day_positions = {} + res.day_positions[dayId] = tu.day_plan_position + } } setTransportPosVersion(v => v + 1) - await reservationsApi.updatePositions(tripId, transportUpdates) + await reservationsApi.updatePositions(tripId, transportUpdates, dayId) } if (prevAssignmentIds.length) { const capturedDayId = dayId diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index a061d1c..aa3a3e9 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -843,6 +843,27 @@ function runMigrations(db: Database.Database): void { const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)'); for (const b of bagsWithUser) ins.run(b.id, b.user_id); }, + // Migration: Per-day positions for multi-day reservations + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS reservation_day_positions ( + reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + position REAL NOT NULL, + PRIMARY KEY (reservation_id, day_id) + ); + `); + // Migrate existing global positions to per-day entries + const reservations = db.prepare('SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL').all() as any[]; + const ins = db.prepare('INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)'); + for (const r of reservations) { + const startDate = r.reservation_time?.split('T')[0]; + const endDate = r.reservation_end_time?.split('T')[0] || startDate; + if (!startDate) continue; + const matchingDays = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?').all(r.trip_id, startDate, endDate) as { id: number }[]; + for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts index 05b7503..b3e233d 100644 --- a/server/src/routes/reservations.ts +++ b/server/src/routes/reservations.ts @@ -91,10 +91,11 @@ router.put('/positions', authenticate, (req: Request, res: Response) => { if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' }); - updatePositions(tripId, positions); + const { day_id } = req.body; + updatePositions(tripId, positions, day_id); res.json({ success: true }); - broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string); + broadcast(tripId, 'reservation:positions', { positions, day_id }, req.headers['x-socket-id'] as string); }); router.put('/:id', authenticate, (req: Request, res: Response) => { diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts index bbb5d18..177ea51 100644 --- a/server/src/services/reservationService.ts +++ b/server/src/services/reservationService.ts @@ -6,7 +6,7 @@ export function verifyTripAccess(tripId: string | number, userId: number) { } export function listReservations(tripId: string | number) { - return db.prepare(` + const reservations = db.prepare(` SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, ap.place_id as accommodation_place_id, acc_p.name as accommodation_name FROM reservations r @@ -16,7 +16,27 @@ export function listReservations(tripId: string | number) { LEFT JOIN places acc_p ON ap.place_id = acc_p.id WHERE r.trip_id = ? ORDER BY r.reservation_time ASC, r.created_at ASC - `).all(tripId); + `).all(tripId) as any[]; + + // Attach per-day positions for multi-day reservations + const dayPositions = db.prepare(` + SELECT rdp.reservation_id, rdp.day_id, rdp.position + FROM reservation_day_positions rdp + JOIN reservations r ON rdp.reservation_id = r.id + WHERE r.trip_id = ? + `).all(tripId) as { reservation_id: number; day_id: number; position: number }[]; + + const posMap = new Map>(); + for (const dp of dayPositions) { + if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {}); + posMap.get(dp.reservation_id)![dp.day_id] = dp.position; + } + + for (const r of reservations) { + r.day_positions = posMap.get(r.id) || null; + } + + return reservations; } export function getReservationWithJoins(id: string | number) { @@ -117,14 +137,35 @@ export function createReservation(tripId: string | number, data: CreateReservati return { reservation, accommodationCreated }; } -export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[]) { - const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?'); - const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => { - for (const item of items) { - stmt.run(item.day_plan_position, item.id, tripId); - } - }); - updateMany(positions); +export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[], dayId?: number | string) { + if (dayId) { + // Per-day positions for multi-day reservations + const stmt = db.prepare('INSERT OR REPLACE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)'); + const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => { + for (const item of items) { + stmt.run(item.id, dayId, item.day_plan_position); + } + }); + updateMany(positions); + } else { + // Legacy: update global position + const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?'); + const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => { + for (const item of items) { + stmt.run(item.day_plan_position, item.id, tripId); + } + }); + updateMany(positions); + } +} + +export function getDayPositions(tripId: string | number, dayId: number | string) { + return db.prepare(` + SELECT rdp.reservation_id, rdp.position + FROM reservation_day_positions rdp + JOIN reservations r ON rdp.reservation_id = r.id + WHERE r.trip_id = ? AND rdp.day_id = ? + `).all(tripId, dayId) as { reservation_id: number; position: number }[]; } export function getReservation(id: string | number, tripId: string | number) {