From 13162c092005a4362d05b36ed7fb3b2f6e559e4a Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 20:38:53 +0200 Subject: [PATCH 1/2] fix(trips): copy todo_items and budget_category_order when duplicating a trip Both tables were added after the original copy logic in #270 and were silently omitted on copy. todo_items are copied with checked reset to 0 and assigned_user_id nulled; budget_category_order rows are copied verbatim. Adds TRIP-027 regression test. Closes #786 --- server/src/services/tripService.ts | 18 ++++++++++ server/tests/integration/trips.test.ts | 46 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index c7573550..1f129d2e 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -681,6 +681,24 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number, if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order); } + const oldTodos = db.prepare('SELECT * FROM todo_items WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertTodo = db.prepare(` + INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, assigned_user_id, priority) + VALUES (?, ?, 0, ?, ?, ?, ?, NULL, ?) + `); + for (const t of oldTodos) { + insertTodo.run(newTripId, t.name, t.category, t.sort_order, t.due_date, t.description, t.priority); + } + + const oldCategoryOrder = db.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertCategoryOrder = db.prepare(` + INSERT INTO budget_category_order (trip_id, category, sort_order) + VALUES (?, ?, ?) + `); + for (const o of oldCategoryOrder) { + insertCategoryOrder.run(newTripId, o.category, o.sort_order); + } + return Number(newTripId); }); diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index f24fe9f1..f24905b1 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -950,6 +950,52 @@ describe('Copy trip with data', () => { expect(newNotes).toHaveLength(1); expect(newNotes[0].text).toBe('Pack early!'); }); + + it('TRIP-027 — copy preserves todos (unchecked, unassigned) and budget category order', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Todo Trip' }); + + // Two todos: one checked and assigned — both should arrive unchecked and unassigned + testDb.prepare( + 'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).run(trip.id, 'Buy tickets', 0, 'Transport', 0, '2026-06-01', 'Check Ryanair', 1); + testDb.prepare( + 'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, assigned_user_id, priority) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(trip.id, 'Book hotel', 1, 'Accommodation', 1, user.id, 0); + + // Two budget category order rows + const insOrder = testDb.prepare('INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)'); + insOrder.run(trip.id, 'Transport', 0); + insOrder.run(trip.id, 'Accommodation', 1); + + const res = await request(app) + .post(`/api/trips/${trip.id}/copy`) + .set('Cookie', authCookie(user.id)) + .send({ title: 'Todo Trip (Copy)' }); + + expect(res.status).toBe(201); + const newId = res.body.trip.id; + + // Todos copied with checked reset and assigned_user_id nulled + const newTodos = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[]; + expect(newTodos).toHaveLength(2); + expect(newTodos[0].name).toBe('Buy tickets'); + expect(newTodos[0].category).toBe('Transport'); + expect(newTodos[0].checked).toBe(0); + expect(newTodos[0].assigned_user_id).toBeNull(); + expect(newTodos[0].due_date).toBe('2026-06-01'); + expect(newTodos[0].description).toBe('Check Ryanair'); + expect(newTodos[0].priority).toBe(1); + expect(newTodos[1].name).toBe('Book hotel'); + expect(newTodos[1].checked).toBe(0); + expect(newTodos[1].assigned_user_id).toBeNull(); + + // Budget category order copied + const newOrder = testDb.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[]; + expect(newOrder).toHaveLength(2); + expect(newOrder[0]).toMatchObject({ category: 'Transport', sort_order: 0 }); + expect(newOrder[1]).toMatchObject({ category: 'Accommodation', sort_order: 1 }); + }); }); // ───────────────────────────────────────────────────────────────────────────── From 09431f725cd5742667972ead12fef9f01d850a93 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 20:45:23 +0200 Subject: [PATCH 2/2] feat(dashboard): add pre-copy confirmation modal showing what will and won't be copied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces CopyTripDialog — a two-section modal that appears before the copy action and lists what is carried over (days, places, budget items, packing lists, TODOs, notes) and what is intentionally skipped (collaborators, collab data, files, share tokens). Addresses the UX gap raised in #786. --- .../src/components/shared/CopyTripDialog.tsx | 108 ++++++++++++++++++ client/src/i18n/translations/en.ts | 14 +++ client/src/pages/DashboardPage.test.tsx | 8 ++ client/src/pages/DashboardPage.tsx | 17 ++- 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 client/src/components/shared/CopyTripDialog.tsx diff --git a/client/src/components/shared/CopyTripDialog.tsx b/client/src/components/shared/CopyTripDialog.tsx new file mode 100644 index 00000000..efea7afb --- /dev/null +++ b/client/src/components/shared/CopyTripDialog.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useCallback } from 'react' +import { Check, X } from 'lucide-react' +import { useTranslation } from '../../i18n' + +interface CopyTripDialogProps { + isOpen: boolean + tripTitle: string + onClose: () => void + onConfirm: () => void +} + +const WILL_COPY_KEYS = [ + 'dashboard.confirm.copy.will1', + 'dashboard.confirm.copy.will2', + 'dashboard.confirm.copy.will3', + 'dashboard.confirm.copy.will4', + 'dashboard.confirm.copy.will5', + 'dashboard.confirm.copy.will6', +] + +const WONT_COPY_KEYS = [ + 'dashboard.confirm.copy.wont1', + 'dashboard.confirm.copy.wont2', + 'dashboard.confirm.copy.wont3', + 'dashboard.confirm.copy.wont4', +] + +export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }: CopyTripDialogProps) { + const { t } = useTranslation() + + const handleEsc = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + }, [onClose]) + + useEffect(() => { + if (isOpen) document.addEventListener('keydown', handleEsc) + return () => document.removeEventListener('keydown', handleEsc) + }, [isOpen, handleEsc]) + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()} + > +

+ {t('dashboard.confirm.copy.title')} +

+

+ {tripTitle} +

+ +
+
+

+ {t('dashboard.confirm.copy.willCopy')} +

+
    + {WILL_COPY_KEYS.map(key => ( +
  • + + {t(key)} +
  • + ))} +
+
+ +
+

+ {t('dashboard.confirm.copy.wontCopy')} +

+
    + {WONT_COPY_KEYS.map(key => ( +
  • + + {t(key)} +
  • + ))} +
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index e444d957..a50ffcc3 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -122,6 +122,20 @@ const en: Record = { 'dashboard.toast.copied': 'Trip copied!', 'dashboard.toast.copyError': 'Failed to copy trip', 'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.', + 'dashboard.confirm.copy.title': 'Copy this trip?', + 'dashboard.confirm.copy.willCopy': 'Will be copied', + 'dashboard.confirm.copy.will1': 'Days, places & day assignments', + 'dashboard.confirm.copy.will2': 'Accommodations & reservations', + 'dashboard.confirm.copy.will3': 'Budget items & category order', + 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', + 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', + 'dashboard.confirm.copy.will6': 'Day notes', + 'dashboard.confirm.copy.wontCopy': "Won't be copied", + 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', + 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', + 'dashboard.confirm.copy.wont3': 'Files & photos', + 'dashboard.confirm.copy.wont4': 'Share tokens', + 'dashboard.confirm.copy.confirm': 'Copy trip', 'dashboard.editTrip': 'Edit Trip', 'dashboard.createTrip': 'Create New Trip', 'dashboard.tripTitle': 'Title', diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index 4aac3121..2a3530ee 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -401,6 +401,10 @@ describe('DashboardPage', () => { const copyButtons = screen.getAllByRole('button', { name: /copy/i }); await user.click(copyButtons[0]); + // Confirm the copy dialog + const confirmButton = await screen.findByRole('button', { name: /copy trip/i }); + await user.click(confirmButton); + await waitFor(() => { expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument(); }); @@ -766,6 +770,10 @@ describe('DashboardPage', () => { expect(copyButtons.length).toBeGreaterThan(0); await user.click(copyButtons[0]); + // Confirm the copy dialog + const confirmButton = await screen.findByRole('button', { name: /copy trip/i }); + await user.click(confirmButton); + await waitFor(() => { expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0); }); diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 321ba3ef..c3e4fa85 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -12,6 +12,7 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget' import TimezoneWidget from '../components/Dashboard/TimezoneWidget' import TripFormModal from '../components/Trips/TripFormModal' import ConfirmDialog from '../components/shared/ConfirmDialog' +import CopyTripDialog from '../components/shared/CopyTripDialog' import { useToast } from '../components/shared/Toast' import { useCountUp } from '../hooks/useCountUp' import { @@ -699,6 +700,7 @@ export default function DashboardPage(): React.ReactElement { const [showWidgetSettings, setShowWidgetSettings] = useState(false) const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid') const [deleteTrip, setDeleteTrip] = useState(null) + const [copyTrip, setCopyTrip] = useState(null) const toggleViewMode = () => { setViewMode(prev => { @@ -815,14 +817,18 @@ export default function DashboardPage(): React.ReactElement { setArchivedTrips(prev => prev.map(update)) } - const handleCopy = async (trip: DashboardTrip) => { + const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip) + + const confirmCopy = async () => { + if (!copyTrip) return try { - const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` }) + const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` }) setTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.copied')) } catch { toast.error(t('dashboard.toast.copyError')) } + setCopyTrip(null) } const today = new Date().toISOString().split('T')[0] @@ -1205,6 +1211,13 @@ export default function DashboardPage(): React.ReactElement { message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })} /> + setCopyTrip(null)} + onConfirm={confirmCopy} + /> +