From 3977a5ecba45ac46debd489098b8da71d14f0f9a Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 31 May 2026 15:42:39 +0200 Subject: [PATCH] Derive client domain types from the shared schema contracts Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change. --- .../components/Budget/BudgetPanel.test.tsx | 22 +- client/src/components/Collab/CollabNotes.tsx | 9 +- client/src/components/Collab/CollabPanel.tsx | 4 +- .../Collab/WhatsNextWidget.test.tsx | 39 +-- .../src/components/Files/FileManager.test.tsx | 18 +- client/src/components/Files/FileManager.tsx | 2 +- .../Layout/InAppNotificationBell.test.tsx | 20 +- client/src/components/Map/RouteCalculator.ts | 4 +- .../InAppNotificationItem.test.tsx | 17 +- client/src/components/PDF/TripPDF.tsx | 7 +- .../components/Packing/PackingListPanel.tsx | 6 +- .../components/Photos/PhotoUpload.test.tsx | 5 +- .../Planner/DayPlanSidebar.test.tsx | 4 +- .../src/components/Planner/DayPlanSidebar.tsx | 17 +- .../src/components/Planner/PlaceFormModal.tsx | 4 + .../Planner/PlaceInspector.test.tsx | 12 +- .../src/components/Planner/PlaceInspector.tsx | 1 + .../components/Planner/ReservationModal.tsx | 6 +- client/src/components/Trips/TripFormModal.tsx | 15 +- .../Trips/TripMembersModal.test.tsx | 14 +- client/src/components/Vacay/VacayCalendar.tsx | 2 +- .../components/Vacay/VacayMonthCard.test.tsx | 18 +- client/src/components/Vacay/VacayStats.tsx | 18 +- client/src/components/shared/CustomSelect.tsx | 8 +- client/src/pages/AdminPage.test.tsx | 32 +-- client/src/pages/DashboardPage.test.tsx | 14 +- client/src/pages/PhotosPage.test.tsx | 4 +- .../src/pages/tripPlanner/useTripPlanner.ts | 5 +- client/src/store/slices/budgetSlice.test.ts | 2 +- client/src/store/slices/budgetSlice.ts | 14 +- client/src/store/slices/remoteEventHandler.ts | 6 +- client/src/store/vacayStore.ts | 4 +- client/src/types.ts | 259 +++++------------- client/src/utils/formatters.ts | 2 +- client/tests/helpers/factories.ts | 37 ++- client/tests/helpers/store.ts | 12 +- client/tests/integration/api/client.test.ts | 10 +- client/tests/unit/db/offlineDb.test.ts | 30 +- .../unit/remoteEventHandler/budget.test.ts | 16 +- .../remoteEventHandler/reservations.test.ts | 16 +- .../unit/remoteEventHandler/trip.test.ts | 10 +- client/tests/unit/slices/budgetSlice.test.ts | 14 +- .../unit/slices/reservationsSlice.test.ts | 18 +- client/tests/unit/tripStore.test.ts | 4 +- client/tests/unit/utils/formatters.test.ts | 15 +- shared/src/assignment/assignment.schema.ts | 33 +++ shared/src/budget/budget.schema.ts | 38 +++ shared/src/day/day.schema.ts | 34 +++ shared/src/packing/packing.schema.ts | 50 ++++ shared/src/place/place.schema.ts | 86 ++++++ shared/src/reservation/reservation.schema.ts | 85 ++++++ shared/src/trip/trip.schema.ts | 45 +++ 52 files changed, 732 insertions(+), 435 deletions(-) diff --git a/client/src/components/Budget/BudgetPanel.test.tsx b/client/src/components/Budget/BudgetPanel.test.tsx index 244cbc96..fa8e496d 100644 --- a/client/src/components/Budget/BudgetPanel.test.tsx +++ b/client/src/components/Budget/BudgetPanel.test.tsx @@ -66,7 +66,8 @@ describe('BudgetPanel', () => { http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) ); render(); - await screen.findByText('Transport'); + // 'Transport' appears in the category section header and the spend breakdown chart. + expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0); }); it('FE-COMP-BUDGET-006: renders budget table headers', async () => { @@ -76,7 +77,8 @@ describe('BudgetPanel', () => { ); render(); await screen.findByText('Name'); - await screen.findByText('Total'); + // 'Total' appears both as a table header and in the chart total label. + expect((await screen.findAllByText('Total')).length).toBeGreaterThan(0); }); it('FE-COMP-BUDGET-007: shows Budget title heading', async () => { @@ -169,8 +171,9 @@ describe('BudgetPanel', () => { http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })) ); render(); - await screen.findByText('Transport'); - await screen.findByText('Hotels'); + // Each category appears in its section header and again in the breakdown chart. + expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0); + expect((await screen.findAllByText('Hotels')).length).toBeGreaterThan(0); }); it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => { @@ -200,7 +203,8 @@ describe('BudgetPanel', () => { http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) ); render(); - await screen.findByText('ToDelete'); + // 'ToDelete' appears in the category header and the breakdown chart. + expect((await screen.findAllByText('ToDelete')).length).toBeGreaterThan(0); expect(screen.getByTitle('Delete Category')).toBeInTheDocument(); }); @@ -390,7 +394,7 @@ describe('BudgetPanel', () => { const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }), total_price: 75, - members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }], + members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: 0 }], }; server.use( http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), @@ -425,7 +429,7 @@ describe('BudgetPanel', () => { seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } }); // Use a user with id != 1 so they're not the owner seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) }); const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 }; server.use( http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) @@ -439,7 +443,7 @@ describe('BudgetPanel', () => { it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => { seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } }); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) }); const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' }; server.use( http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) @@ -484,7 +488,7 @@ describe('BudgetPanel', () => { it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => { seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } }); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) }); const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null }; server.use( http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 468ac163..df81e941 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -19,6 +19,7 @@ interface NoteFile { filename: string original_name: string mime_type: string + file_size?: number | null url?: string } @@ -39,6 +40,8 @@ interface CollabNote { author?: { username: string; avatar: string | null } user?: { username: string; avatar: string | null } files?: NoteFile[] + // Wire field: collabService embeds note files as `attachments` (with url). + attachments?: NoteFile[] } interface NoteAuthor { @@ -180,7 +183,7 @@ const formatTimestamp = (ts, t, locale) => { if (!ts) return '' const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z') const now = new Date() - const diffMs = now - d + const diffMs = now.getTime() - d.getTime() const diffMins = Math.floor(diffMs / 60000) if (diffMins < 1) return t('collab.chat.justNow') || 'just now' if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago` @@ -240,7 +243,7 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) { // ── New Note Modal (portal to body) ───────────────────────────────────────── interface NoteFormModalProps { onClose: () => void - onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise + onSubmit: (data: { title: string; content: string; category: string | null; website: string | null; color?: string | null; _pendingFiles?: File[]; files?: File[] }) => Promise onDeleteFile?: (noteId: number, fileId: number) => Promise existingCategories: string[] categoryColors: Record @@ -849,7 +852,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi )} {/* Right: website + attachment thumbnails */} - {(note.website || note.attachments?.length > 0) && ( + {(note.website || (note.attachments?.length ?? 0) > 0) && (
{/* Website */} {note.website && ( diff --git a/client/src/components/Collab/CollabPanel.tsx b/client/src/components/Collab/CollabPanel.tsx index 55582f82..767d61f0 100644 --- a/client/src/components/Collab/CollabPanel.tsx +++ b/client/src/components/Collab/CollabPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, type CSSProperties } from 'react' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react' @@ -17,7 +17,7 @@ function useIsDesktop(breakpoint = 1024) { return isDesktop } -const card = { +const card: CSSProperties = { display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)', overflow: 'hidden', minHeight: 0, diff --git a/client/src/components/Collab/WhatsNextWidget.test.tsx b/client/src/components/Collab/WhatsNextWidget.test.tsx index b202e5c9..668bdd2b 100644 --- a/client/src/components/Collab/WhatsNextWidget.test.tsx +++ b/client/src/components/Collab/WhatsNextWidget.test.tsx @@ -32,22 +32,23 @@ function makeAssignment(id: number, placeOverrides: Record = {} notes: null, place: { id, - trip_id: 1, name: `Place ${id}`, description: null, lat: 0, lng: 0, address: null, category_id: null, - icon: null, price: null, + currency: null, image_url: null, google_place_id: null, - osm_id: null, - route_geometry: null, place_time: null, end_time: null, - created_at: '2025-01-01T00:00:00.000Z', + duration_minutes: 60, + notes: null, + transport_mode: 'walking', + website: null, + phone: null, ...placeOverrides, }, participants, @@ -83,7 +84,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => { seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(10, { place_time: '08:00' })], }, @@ -95,7 +96,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => { seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(20, { name: 'Eiffel Tower' })], }, @@ -106,7 +107,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => { seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(21, { name: 'Museum' })], }, @@ -118,7 +119,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => { seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })], }, @@ -130,7 +131,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => { seedStore(useSettingsStore, { settings: { time_format: '24h' } }) seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })], }, @@ -142,7 +143,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => { seedStore(useSettingsStore, { settings: { time_format: '12h' } }) seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })], }, @@ -153,7 +154,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => { seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(32, { name: 'Free Time', place_time: null })], }, @@ -164,7 +165,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-009: renders address when provided', () => { seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })], }, @@ -179,7 +180,7 @@ describe('WhatsNextWidget', () => { trip_id: 1, date: getFutureDate(i + 1), title: null, - order: i, + day_number: i, assignments: [], notes_items: [], notes: null, @@ -207,7 +208,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => { seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])], }, @@ -218,7 +219,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => { seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(41, { name: 'Park' }, [])], }, @@ -229,7 +230,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => { seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })], }, @@ -241,7 +242,7 @@ describe('WhatsNextWidget', () => { it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => { seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [ makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }), @@ -263,7 +264,7 @@ describe('WhatsNextWidget', () => { if (now.getHours() > 0) { const pastTime = '00:01' // Very early — will be past for most of the day seedStore(useTripStore, { - days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }], + days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }], assignments: { '1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })], }, diff --git a/client/src/components/Files/FileManager.test.tsx b/client/src/components/Files/FileManager.test.tsx index 273387c3..4db7b40c 100644 --- a/client/src/components/Files/FileManager.test.tsx +++ b/client/src/components/Files/FileManager.test.tsx @@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip } from '../../../tests/helpers/factories'; +import type { TripFile } from '../../types'; import FileManager from './FileManager'; // Mock getAuthUrl @@ -36,20 +37,21 @@ vi.mock('../../api/client', async (importOriginal) => { import { filesApi } from '../../api/client'; -const buildFile = (overrides = {}) => ({ +const buildFile = (overrides: Partial = {}): TripFile => ({ id: 1, + trip_id: 1, + filename: 'report.pdf', original_name: 'report.pdf', mime_type: 'application/pdf', file_size: 51200, created_at: '2025-01-10T08:00:00Z', url: '/uploads/trips/1/report.pdf', - starred: false, + starred: 0, deleted_at: null, place_id: null, reservation_id: null, - day_id: null, uploaded_by: 1, - uploader_name: 'Alice', + uploaded_by_name: 'Alice', ...overrides, }); @@ -388,7 +390,7 @@ describe('FileManager', () => { it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => { const { buildReservation } = await import('../../../tests/helpers/factories'); - const reservation = buildReservation({ id: 20, name: 'Hotel Paris' }); + const reservation = buildReservation({ id: 20, title: 'Hotel Paris' }); render(); const user = userEvent.setup(); @@ -418,7 +420,7 @@ describe('FileManager', () => { it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => { const { buildReservation } = await import('../../../tests/helpers/factories'); - const reservation = buildReservation({ id: 20, name: 'Train Ticket' }); + const reservation = buildReservation({ id: 20, title: 'Train Ticket' }); const file = buildFile({ id: 1 }); render(); const user = userEvent.setup(); @@ -436,7 +438,7 @@ describe('FileManager', () => { it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => { const { buildPlace, buildReservation } = await import('../../../tests/helpers/factories'); const place = buildPlace({ id: 10, name: 'Notre Dame' }); - const reservation = buildReservation({ id: 20, name: 'Airbnb' }); + const reservation = buildReservation({ id: 20, title: 'Airbnb' }); render(); const user = userEvent.setup(); @@ -527,7 +529,7 @@ describe('FileManager', () => { it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => { const { buildReservation } = await import('../../../tests/helpers/factories'); - const reservation = buildReservation({ id: 20, name: 'Museum Pass' }); + const reservation = buildReservation({ id: 20, title: 'Museum Pass' }); // File already has reservation_id set to 20 const file = buildFile({ id: 1, reservation_id: 20 }); diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index bf143245..00297ffd 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -739,7 +739,7 @@ function AssignModal(S: FileManagerState) { onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}> - {r.title || r.name} + {r.title} {isLinked && } ) diff --git a/client/src/components/Layout/InAppNotificationBell.test.tsx b/client/src/components/Layout/InAppNotificationBell.test.tsx index 8c666113..45ccea8b 100644 --- a/client/src/components/Layout/InAppNotificationBell.test.tsx +++ b/client/src/components/Layout/InAppNotificationBell.test.tsx @@ -7,9 +7,10 @@ import { useInAppNotificationStore } from '../../store/inAppNotificationStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser } from '../../../tests/helpers/factories'; import InAppNotificationBell from './InAppNotificationBell'; +import type { InAppNotification } from '../../store/inAppNotificationStore'; let _notifId = 1; -function buildNotification(overrides: Record = {}) { +function buildNotification(overrides: Partial = {}): InAppNotification { return { id: _notifId++, type: 'simple', @@ -20,15 +21,15 @@ function buildNotification(overrides: Record = {}) { sender_avatar: null, recipient_id: 1, title_key: 'test', - title_params: '{}', + title_params: {}, text_key: 'test.text', - text_params: '{}', + text_params: {}, positive_text_key: null, negative_text_key: null, response: null, navigate_text_key: null, navigate_target: null, - is_read: 0, + is_read: false, created_at: '2025-01-01T00:00:00.000Z', ...overrides, }; @@ -92,14 +93,7 @@ describe('InAppNotificationBell', () => { it('FE-COMP-BELL-007: panel shows Mark all read button when panel is open', async () => { const user = userEvent.setup(); - const notification = { - id: 1, type: 'simple', scope: 'trip', target: 1, sender_id: 2, - sender_username: 'alice', sender_avatar: null, recipient_id: 1, - title_key: 'test', title_params: '{}', text_key: 'test.text', text_params: '{}', - positive_text_key: null, negative_text_key: null, response: null, - navigate_text_key: null, navigate_target: null, is_read: 0, - created_at: '2025-01-01T00:00:00.000Z', - }; + const notification = buildNotification({ id: 1, title_key: 'test', text_key: 'test.text' }); seedStore(useInAppNotificationStore, { notifications: [notification], unreadCount: 1, isLoading: false }); render(); const bell = screen.getAllByRole('button')[0]; @@ -153,7 +147,7 @@ describe('InAppNotificationBell', () => { it('FE-COMP-BELL-013: Mark all read button NOT shown when unreadCount is 0', async () => { const user = userEvent.setup(); - seedStore(useInAppNotificationStore, { notifications: [buildNotification({ is_read: 1 })], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn(), fetchUnreadCount: vi.fn() }); + seedStore(useInAppNotificationStore, { notifications: [buildNotification({ is_read: true })], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn(), fetchUnreadCount: vi.fn() }); render(); await user.click(screen.getAllByRole('button')[0]); await screen.findByText('Notifications'); diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index 53ca76be..8a2a9e3b 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -78,12 +78,12 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null { } /** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */ -export function optimizeRoute(places: Waypoint[]): Waypoint[] { +export function optimizeRoute(places: T[]): T[] { const valid = places.filter((p) => p.lat && p.lng) if (valid.length <= 2) return places const visited = new Set() - const result: Waypoint[] = [] + const result: T[] = [] let current = valid[0] visited.add(0) result.push(current) diff --git a/client/src/components/Notifications/InAppNotificationItem.test.tsx b/client/src/components/Notifications/InAppNotificationItem.test.tsx index 1eb024bc..f6a3a7ef 100644 --- a/client/src/components/Notifications/InAppNotificationItem.test.tsx +++ b/client/src/components/Notifications/InAppNotificationItem.test.tsx @@ -6,9 +6,10 @@ import { useSettingsStore } from '../../store/settingsStore'; import { useInAppNotificationStore } from '../../store/inAppNotificationStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildSettings } from '../../../tests/helpers/factories'; +import type { InAppNotification } from '../../store/inAppNotificationStore'; import InAppNotificationItem from './InAppNotificationItem'; -const buildNotification = (overrides = {}) => ({ +const buildNotification = (overrides: Partial = {}): InAppNotification => ({ id: 1, type: 'simple', scope: 'trip', @@ -18,15 +19,15 @@ const buildNotification = (overrides = {}) => ({ sender_avatar: null, recipient_id: 1, title_key: 'notifications.title', - title_params: '{}', + title_params: {}, text_key: 'notifications.empty', - text_params: '{}', + text_params: {}, positive_text_key: null, negative_text_key: null, response: null, navigate_text_key: null, navigate_target: null, - is_read: 0, + is_read: false, created_at: new Date().toISOString(), ...overrides, }); @@ -62,12 +63,12 @@ describe('InAppNotificationItem', () => { }); it('FE-COMP-NOTIF-005: shows Mark as read button for unread notification', () => { - render(); + render(); expect(screen.getByTitle('Mark as read')).toBeInTheDocument(); }); it('FE-COMP-NOTIF-006: does not show Mark as read button for read notification', () => { - render(); + render(); expect(screen.queryByTitle('Mark as read')).not.toBeInTheDocument(); }); @@ -80,7 +81,7 @@ describe('InAppNotificationItem', () => { const user = userEvent.setup(); const markRead = vi.fn().mockResolvedValue(undefined); seedStore(useInAppNotificationStore, { markRead }); - render(); + render(); await user.click(screen.getByTitle('Mark as read')); expect(markRead).toHaveBeenCalledWith(42); }); @@ -190,7 +191,7 @@ describe('InAppNotificationItem', () => { type: 'navigate', navigate_text_key: 'notifications.title', navigate_target: '/trips/1', - is_read: 0, + is_read: false, })} onClose={onClose} /> diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index ce5ae349..dc4016e9 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -3,7 +3,7 @@ import { createElement } from 'react' import { getCategoryIcon } from '../shared/categoryIcons' import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react' import { accommodationsApi, mapsApi } from '../../api/client' -import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' +import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types' import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder' import { splitReservationDateTime } from '../../utils/formatters' @@ -117,7 +117,8 @@ interface downloadTripPDFProps { places: Place[] assignments: AssignmentsMap categories: Category[] - dayNotes: DayNotesMap + // Flattened across days: each note carries its own day_id (see downloadTripPDF callers). + dayNotes: DayNote[] reservations?: any[] t: (key: string, params?: Record) => string locale: string @@ -190,7 +191,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor .filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle')) const merged = [] - assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) + assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? 0, data: a })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) dayReservations.forEach(r => { const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index a5ceedd1..a2e1c86b 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -9,7 +9,7 @@ import { CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload, } from 'lucide-react' -import type { PackingItem } from '../../types' +import type { PackingItem, PackingBag } from '../../types' const VORSCHLAEGE = [ { name: 'Passport', category: 'Documents' }, @@ -67,8 +67,6 @@ function katColor(kat, allCategories) { return KAT_COLORS[Math.abs(h) % KAT_COLORS.length] } -interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null } - /** Weight an item contributes to a total: unit weight times quantity (defaults: 0 g, qty 1). */ export const itemWeight = (i: { weight_grams?: number | null; quantity?: number | null }): number => (i.weight_grams || 0) * (i.quantity || 1) @@ -818,7 +816,7 @@ function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSigna if (filter === 'erledigt') return i.checked return true }) - const groups = {} + const groups: Record = {} for (const item of filtered) { const kat = item.category || t('packing.defaultCategory') if (!groups[kat]) groups[kat] = [] diff --git a/client/src/components/Photos/PhotoUpload.test.tsx b/client/src/components/Photos/PhotoUpload.test.tsx index cbd6e0ea..bd9de2e2 100644 --- a/client/src/components/Photos/PhotoUpload.test.tsx +++ b/client/src/components/Photos/PhotoUpload.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event' import { vi, describe, it, expect, beforeEach, beforeAll } from 'vitest' import { render } from '../../../tests/helpers/render' import { resetAllStores } from '../../../tests/helpers/store' +import type { Day, Place } from '../../types' import { PhotoUpload } from './PhotoUpload' beforeAll(() => { @@ -12,8 +13,8 @@ beforeAll(() => { const defaultProps = { tripId: 1, - days: [{ id: 1, day_number: 1, date: null }], - places: [{ id: 1, name: 'Eiffel Tower' }], + days: [{ id: 1, trip_id: 1, day_number: 1, date: null }] as Day[], + places: [{ id: 1, trip_id: 1, name: 'Eiffel Tower' }] as Place[], onUpload: vi.fn().mockResolvedValue(undefined), onClose: vi.fn(), } diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 3d00931c..74358385 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -1393,7 +1393,7 @@ describe('DayPlanSidebar', () => { const assignment = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) const flight = buildReservation({ id: 77, trip_id: 1, type: 'flight', status: 'confirmed', - date: '2025-06-01', reservation_time: '2025-06-01T10:00:00Z', + reservation_time: '2025-06-01T10:00:00Z', }) render( { const a2 = buildAssignment({ id: 22, day_id: 10, order_index: 1, place: placeB }) const flight = buildReservation({ id: 77, trip_id: 1, type: 'flight', status: 'confirmed', - date: '2025-06-01', reservation_time: '2025-06-01T12:00:00Z', + reservation_time: '2025-06-01T12:00:00Z', }) render( void onPlaceClick: (placeId: number) => void onDayDetail: (day: Day) => void - accommodations?: Assignment[] + accommodations?: Accommodation[] onReorder: (dayId: number, orderedIds: number[]) => void onUpdateDayTitle: (dayId: number, title: string) => void onRouteCalculated: (dayId: number, route: RouteResult | null) => void @@ -277,9 +277,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const [expandedDays, setExpandedDays] = useState(() => { try { const saved = sessionStorage.getItem(`day-expanded-${tripId}`) - if (saved) return new Set(JSON.parse(saved)) + if (saved) return new Set(JSON.parse(saved) as number[]) } catch {} - return new Set(days.map(d => d.id)) + return new Set(days.map(d => d.id)) }) useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays]) const [editingDayId, setEditingDayId] = useState(null) @@ -921,7 +921,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const totalCost = useMemo(() => days.reduce((s, d) => { const da = assignments[String(d.id)] || [] - return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0) + return s + da.reduce((s2, a) => s2 + (Number(a.place?.price) || 0), 0) }, 0), [days, assignments]) // Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort @@ -1416,8 +1416,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP > {/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */} {(() => { - const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat - const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng + // anyGeoPlace is an assignment (has .place) or a bare place — read coords from either. + const geoLat = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lat : anyGeoPlace.lat) : undefined + const geoLng = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lng : anyGeoPlace.lng) : undefined + const wLat = loc?.place?.lat ?? geoLat + const wLng = loc?.place?.lng ?? geoLng const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null) return (
{ }); it('FE-PLANNER-INSPECTOR-017: "Remove from day" button appears when place IS assigned to selectedDay', () => { - const assignmentInDay = [{ id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; + const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; render( { it('FE-PLANNER-INSPECTOR-018: clicking remove calls onRemoveAssignment with dayId and assignmentId', async () => { const user = userEvent.setup(); const onRemoveAssignment = vi.fn(); - const assignmentInDay = [{ id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; + const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; render( { it('FE-PLANNER-INSPECTOR-030: linked reservation shown when selectedAssignmentId has a reservation', () => { const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any); - const assignmentInDay = [{ id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; + const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; render( { it('FE-PLANNER-INSPECTOR-031: participants section shown when tripMembers > 1 and selectedAssignmentId is set', () => { const members = [buildUser({ id: 1 }), buildUser({ id: 2 })]; - const assignmentInDay = [{ id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; + const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; render( { const member2 = buildUser({ id: 11, username: 'bob' }); const members = [member1, member2]; const assignmentInDay = [{ - id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null, + id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null, participants: [{ user_id: 10 }], }]; render( @@ -637,7 +637,7 @@ describe('PlaceInspector', () => { tripMembers={[member]} selectedDayId={1} selectedAssignmentId={99} - assignments={{ '1': [{ id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null }] }} + assignments={{ '1': [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }] }} /> ); // "solo" username might be visible from other parts but participants box should not render diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 212e2a9e..7020d57e 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -104,6 +104,7 @@ function formatFileSize(bytes) { interface TripMember { id: number username: string + avatar?: string | null avatar_url?: string | null } diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index c50b388f..b2d3a2fe 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from 'react' +import { useState, useEffect, useRef, useMemo, type CSSProperties } from 'react' import { useParams } from 'react-router-dom' import apiClient from '../../api/client' import { useTripStore } from '../../store/tripStore' @@ -265,12 +265,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p ) : [] - const inputStyle = { + const inputStyle: CSSProperties = { width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)', } - const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' } + const labelStyle: CSSProperties = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' } return ( void - onSave: (data: Record) => Promise | void + // Create returns the new trip (so we can attach members / upload the cover); + // update resolves without a payload. + onSave: (data: Record) => Promise<{ trip?: Trip } | void> | void trip: Trip | null onCoverUpdate: (tripId: number, coverUrl: string) => void } @@ -106,22 +108,23 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp reminder_days: formData.reminder_days, ...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}), }) + const createdTrip = result ? result.trip : undefined // Add selected members for newly created trips - if (selectedMembers.length > 0 && result?.trip?.id) { + if (selectedMembers.length > 0 && createdTrip?.id) { for (const userId of selectedMembers) { const user = allUsers.find(u => u.id === userId) if (user) { - try { await tripsApi.addMember(result.trip.id, user.username) } catch {} + try { await tripsApi.addMember(createdTrip.id, user.username) } catch {} } } } // Upload pending cover for newly created trips - if (pendingCoverFile && result?.trip?.id) { + if (pendingCoverFile && createdTrip?.id) { try { const fd = new FormData() fd.append('cover', pendingCoverFile) - const data = await tripsApi.uploadCover(result.trip.id, fd) - onCoverUpdate?.(result.trip.id, data.cover_image) + const data = await tripsApi.uploadCover(createdTrip.id, fd) + onCoverUpdate?.(createdTrip.id, data.cover_image) } catch { // Cover upload failed but trip was created } diff --git a/client/src/components/Trips/TripMembersModal.test.tsx b/client/src/components/Trips/TripMembersModal.test.tsx index 17ad74ab..d41d62c4 100644 --- a/client/src/components/Trips/TripMembersModal.test.tsx +++ b/client/src/components/Trips/TripMembersModal.test.tsx @@ -179,7 +179,7 @@ describe('TripMembersModal', () => { it('FE-COMP-MEMBERS-016: share link section not rendered for non-owner', async () => { const nonOwner = buildUser({ id: 99, username: 'stranger' }); seedStore(useAuthStore, { user: nonOwner, isAuthenticated: true }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) }); seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); render(); @@ -190,7 +190,7 @@ describe('TripMembersModal', () => { it('FE-COMP-MEMBERS-017: share link section visible for owner', async () => { seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); render(); await screen.findByText('Public Link'); @@ -199,7 +199,7 @@ describe('TripMembersModal', () => { it('FE-COMP-MEMBERS-018: create share link shows URL after clicking create', async () => { const user = userEvent.setup(); seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); // GET returns null token initially; POST returns a new token server.use( @@ -229,7 +229,7 @@ describe('TripMembersModal', () => { it('FE-COMP-MEMBERS-019: copy share link calls clipboard.writeText', async () => { const user = userEvent.setup(); seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, 'clipboard', { @@ -261,7 +261,7 @@ describe('TripMembersModal', () => { it('FE-COMP-MEMBERS-020: delete share link removes URL and shows create button', async () => { const user = userEvent.setup(); seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); let deleteHandlerCalled = false; server.use( @@ -292,7 +292,7 @@ describe('TripMembersModal', () => { it('FE-COMP-MEMBERS-021: clicking permission toggle calls POST with updated perms', async () => { const user = userEvent.setup(); seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); let postedPerms: Record | null = null; server.use( @@ -376,7 +376,7 @@ describe('TripMembersModal', () => { }); seedStore(useAuthStore, { user: memberUser, isAuthenticated: true }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); let deleteCalledForUserId: string | null = null; server.use( diff --git a/client/src/components/Vacay/VacayCalendar.tsx b/client/src/components/Vacay/VacayCalendar.tsx index b3f9b49a..ad5ce753 100644 --- a/client/src/components/Vacay/VacayCalendar.tsx +++ b/client/src/components/Vacay/VacayCalendar.tsx @@ -36,7 +36,7 @@ export default function VacayCalendar() { }, [selectedYear]) const companyHolidaySet = useMemo(() => { - const s = new Set() + const s = new Set() companyHolidays.forEach(h => s.add(h.date)) return s }, [companyHolidays]) diff --git a/client/src/components/Vacay/VacayMonthCard.test.tsx b/client/src/components/Vacay/VacayMonthCard.test.tsx index cd9df5e5..70b8bdd2 100644 --- a/client/src/components/Vacay/VacayMonthCard.test.tsx +++ b/client/src/components/Vacay/VacayMonthCard.test.tsx @@ -49,7 +49,7 @@ describe('VacayMonthCard', () => { it('FE-COMP-VACAYMONTHCARD-004: Holiday cell has tooltip with localName', () => { const props = { ...baseProps, - holidays: { '2025-01-01': { localName: 'Neujahr', label: null, color: '#ef4444' } }, + holidays: { '2025-01-01': { name: 'Neujahr', localName: 'Neujahr', label: null, color: '#ef4444' } }, } render() // Jan 1 is a Wednesday — there may be multiple "1" text nodes, find the one with a title @@ -60,7 +60,7 @@ describe('VacayMonthCard', () => { it('FE-COMP-VACAYMONTHCARD-005: Holiday cell with label shows combined tooltip', () => { const props = { ...baseProps, - holidays: { '2025-01-01': { localName: 'New Year', label: 'DE', color: '#ef4444' } }, + holidays: { '2025-01-01': { name: 'New Year', localName: 'New Year', label: 'DE', color: '#ef4444' } }, } render() const cell = screen.getByTitle('DE: New Year') @@ -95,7 +95,7 @@ describe('VacayMonthCard', () => { it('FE-COMP-VACAYMONTHCARD-008: Single vacation entry renders colored overlay', () => { const props = { ...baseProps, - entryMap: { '2025-01-15': [{ person_color: '#6366f1' }] }, + entryMap: { '2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1' }] }, } render() const daySpan = screen.getByText('15') @@ -111,7 +111,7 @@ describe('VacayMonthCard', () => { it('FE-COMP-VACAYMONTHCARD-009: Day number font-weight is bold when entries exist', () => { const props = { ...baseProps, - entryMap: { '2025-01-20': [{ person_color: '#6366f1' }] }, + entryMap: { '2025-01-20': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1' }] }, } render() const daySpan = screen.getByText('20') @@ -131,7 +131,7 @@ describe('VacayMonthCard', () => { const props = { ...baseProps, entryMap: { - '2025-01-15': [{ person_color: '#6366f1' }, { person_color: '#f43f5e' }], + '2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1' }, { date: '2025-01-15', user_id: 1, person_color: '#f43f5e' }], }, } render() @@ -149,10 +149,10 @@ describe('VacayMonthCard', () => { ...baseProps, entryMap: { '2025-01-15': [ - { person_color: '#6366f1' }, - { person_color: '#f43f5e' }, - { person_color: '#22c55e' }, - { person_color: '#f59e0b' }, + { date: '2025-01-15', user_id: 1, person_color: '#6366f1' }, + { date: '2025-01-15', user_id: 1, person_color: '#f43f5e' }, + { date: '2025-01-15', user_id: 1, person_color: '#22c55e' }, + { date: '2025-01-15', user_id: 1, person_color: '#f59e0b' }, ], }, } diff --git a/client/src/components/Vacay/VacayStats.tsx b/client/src/components/Vacay/VacayStats.tsx index 403207b5..a6d0f438 100644 --- a/client/src/components/Vacay/VacayStats.tsx +++ b/client/src/components/Vacay/VacayStats.tsx @@ -3,14 +3,8 @@ import { Briefcase, Pencil } from 'lucide-react' import { useVacayStore } from '../../store/vacayStore' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' -import type { VacayStat } from '../../types' +import type { VacayStat, TranslationFn } from '../../types' -interface VacayStatExtended extends VacayStat { - username: string - avatar_url: string | null - color: string | null - total_available: number -} export default function VacayStats() { const { t } = useTranslation() @@ -50,17 +44,19 @@ export default function VacayStats() { } interface StatCardProps { - stat: VacayStatExtended + stat: VacayStat isMe: boolean canEdit: boolean selectedYear: number onSave: (userId: number, year: number, days: number) => Promise - t: (key: string) => string + t: TranslationFn } function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardProps) { const [editing, setEditing] = useState(false) - const [localDays, setLocalDays] = useState(s.vacation_days) + // Holds the entitlement-day value while editing: a number on load, a string + // once the user types into the number input. + const [localDays, setLocalDays] = useState(s.vacation_days) const pct = s.total_available > 0 ? Math.min(100, (s.used / s.total_available) * 100) : 0 // Sync local state when stats reload from server @@ -70,7 +66,7 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP const handleSave = () => { setEditing(false) - const days = parseInt(localDays) + const days = parseInt(String(localDays)) if (!isNaN(days) && days >= 0 && days <= 365 && days !== s.vacation_days) { onSave(selectedYear, days, s.user_id) } diff --git a/client/src/components/shared/CustomSelect.tsx b/client/src/components/shared/CustomSelect.tsx index d8b5ddbb..f317432f 100644 --- a/client/src/components/shared/CustomSelect.tsx +++ b/client/src/components/shared/CustomSelect.tsx @@ -3,7 +3,9 @@ import ReactDOM from 'react-dom' import { ChevronDown, Check } from 'lucide-react' interface SelectOption { - value: string + // Callers use both string keys and numeric ids (e.g. day/place ids) as values; + // the component only does strict-equality lookups and key rendering, so either works. + value: string | number label: string icon?: React.ReactNode isHeader?: boolean @@ -13,8 +15,8 @@ interface SelectOption { } interface CustomSelectProps { - value: string - onChange: (value: string) => void + value: string | number + onChange: (value: string | number) => void options?: SelectOption[] placeholder?: string searchable?: boolean diff --git a/client/src/pages/AdminPage.test.tsx b/client/src/pages/AdminPage.test.tsx index d3f851af..b9a472b4 100644 --- a/client/src/pages/AdminPage.test.tsx +++ b/client/src/pages/AdminPage.test.tsx @@ -360,7 +360,7 @@ describe('AdminPage', () => { fireEvent.click(screen.getByRole('button', { name: /settings/i })); const heading = await screen.findByRole('heading', { name: /authentication methods/i }); - const card = heading.closest('.bg-white'); + const card = heading.closest('.bg-white'); const toggles = within(card!).getAllByRole('button'); fireEvent.click(toggles[0]); // First toggle = password_login @@ -474,7 +474,7 @@ describe('AdminPage', () => { fireEvent.click(screen.getByRole('button', { name: /settings/i })); const mfaHeading = await screen.findByRole('heading', { name: /require two-factor/i }); - const mfaCard = mfaHeading.closest('.bg-white'); + const mfaCard = mfaHeading.closest('.bg-white'); const mfaToggle = within(mfaCard!).getByRole('button'); fireEvent.click(mfaToggle); @@ -739,7 +739,7 @@ describe('AdminPage', () => { // Find and click the Save button in the file types section const fileTypesHeading = screen.getByRole('heading', { name: /allowed file types/i }); - const fileTypesCard = fileTypesHeading.closest('.bg-white'); + const fileTypesCard = fileTypesHeading.closest('.bg-white'); const saveBtn = within(fileTypesCard!).getByRole('button', { name: /save/i }); fireEvent.click(saveBtn); @@ -765,7 +765,7 @@ describe('AdminPage', () => { // Wait for OIDC section to appear const oidcHeading = await screen.findByRole('heading', { name: /single sign-on/i }); - const oidcCard = oidcHeading.closest('.bg-white'); + const oidcCard = oidcHeading.closest('.bg-white'); // Type in the display name field (placeholder is 'z.B. Google, Authentik, Keycloak') const displayNameInput = within(oidcCard!).getByPlaceholderText('z.B. Google, Authentik, Keycloak'); @@ -800,7 +800,7 @@ describe('AdminPage', () => { // The Email (SMTP) panel header has the enable toggle const emailHeading = await screen.findByRole('heading', { name: /email \(smtp\)/i }); - const emailPanel = emailHeading.closest('.bg-white'); + const emailPanel = emailHeading.closest('.bg-white'); const emailToggle = within(emailPanel!).getAllByRole('button')[0]; fireEvent.click(emailToggle); @@ -842,7 +842,7 @@ describe('AdminPage', () => { // Click Save in the email panel const emailHeading = screen.getByRole('heading', { name: /email \(smtp\)/i }); - const emailPanel = emailHeading.closest('.bg-white'); + const emailPanel = emailHeading.closest('.bg-white'); const saveBtn = within(emailPanel!).getByRole('button', { name: /^save$/i }); fireEvent.click(saveBtn); @@ -964,7 +964,7 @@ describe('AdminPage', () => { // Wait for the API Keys section to appear const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i }); - const apiKeysCard = apiKeysHeading.closest('.bg-white'); + const apiKeysCard = apiKeysHeading.closest('.bg-white'); // Type in the maps key field (type="password" by default) const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...'); @@ -999,7 +999,7 @@ describe('AdminPage', () => { // Wait for the API Keys section const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i }); - const apiKeysCard = apiKeysHeading.closest('.bg-white'); + const apiKeysCard = apiKeysHeading.closest('.bg-white'); // Type a key value to enable the Test button const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...'); @@ -1126,7 +1126,7 @@ describe('AdminPage', () => { // Click the TLS toggle (skip TLS certificate check) const tlsToggleText = screen.getByText('Skip TLS certificate check'); - const tlsCard = tlsToggleText.closest('div'); + const tlsCard = tlsToggleText.closest('div'); // The toggle button is a sibling container const allToggles = screen.getAllByRole('button'); // Find toggle near the TLS text @@ -1170,7 +1170,7 @@ describe('AdminPage', () => { // Find the email panel and click its "Send test email" button (scoped to avoid admin webhook panel) const emailHeading = screen.getByRole('heading', { name: /email \(smtp\)/i }); - const emailPanel = emailHeading.closest('.bg-white'); + const emailPanel = emailHeading.closest('.bg-white'); const testBtn = within(emailPanel!).getByRole('button', { name: /send test email/i }); fireEvent.click(testBtn); @@ -1207,7 +1207,7 @@ describe('AdminPage', () => { // Find the webhook panel heading ('Webhook') — exact match to avoid 'Admin Webhook' const webhookHeading = screen.getByRole('heading', { name: /^webhook$/i }); - const webhookCard = webhookHeading.closest('.bg-white'); + const webhookCard = webhookHeading.closest('.bg-white'); // Find the toggle button in webhook card const webhookToggle = within(webhookCard!).getByRole('button'); fireEvent.click(webhookToggle); @@ -1245,7 +1245,7 @@ describe('AdminPage', () => { // Find the Save button in the admin webhook panel const adminWebhookHeading = screen.getByRole('heading', { name: /admin webhook/i }); - const adminWebhookCard = adminWebhookHeading.closest('.bg-white'); + const adminWebhookCard = adminWebhookHeading.closest('.bg-white'); const saveBtn = within(adminWebhookCard!).getByRole('button', { name: /save/i }); fireEvent.click(saveBtn); @@ -1284,7 +1284,7 @@ describe('AdminPage', () => { // The channel column header is t('settings.notificationPreferences.email') = 'Email' (CSS uppercases it) // Find the AdminNotificationsPanel by its h2 heading role='heading' const matrixHeading = await screen.findByRole('heading', { name: /^notifications$/i }); - const matrixCard = matrixHeading.closest('.bg-white'); + const matrixCard = matrixHeading.closest('.bg-white'); // The matrix toggle button is inside the card (not a checkbox — it's a button toggle) const matrixToggle = matrixCard?.querySelector('button'); @@ -1308,7 +1308,7 @@ describe('AdminPage', () => { // Wait for the OIDC section — heading is 'Single Sign-On (OIDC)' const oidcHeading = await screen.findByRole('heading', { name: /single sign-on/i }); - const oidcCard = oidcHeading.closest('.bg-white'); + const oidcCard = oidcHeading.closest('.bg-white'); // Issuer field (placeholder: https://accounts.google.com) const issuerInput = within(oidcCard!).getByPlaceholderText('https://accounts.google.com'); @@ -1320,12 +1320,12 @@ describe('AdminPage', () => { // Client ID field const clientIdLabel = within(oidcCard!).getByText('Client ID'); - const clientIdInput = clientIdLabel.closest('div')!.querySelector('input')!; + const clientIdInput = clientIdLabel.closest('div')!.querySelector('input')!; fireEvent.change(clientIdInput, { target: { value: 'my-client-id' } }); // Client Secret field const clientSecretLabel = within(oidcCard!).getByText('Client Secret'); - const clientSecretInput = clientSecretLabel.closest('div')!.querySelector('input')!; + const clientSecretInput = clientSecretLabel.closest('div')!.querySelector('input')!; fireEvent.change(clientSecretInput, { target: { value: 'my-client-secret' } }); // Verify the inputs updated diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index 9d704e25..d968dc7e 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -209,7 +209,7 @@ describe('DashboardPage', () => { describe('FE-PAGE-DASH-011: Archive trip moves it to the archive filter', () => { it('archiving a trip removes it from active and shows it under the archive filter', async () => { - const archivedTrip = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10', is_archived: true }); + const archivedTrip = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10', is_archived: 1 }); server.use( http.put('/api/trips/:id', () => HttpResponse.json({ trip: archivedTrip })), ); @@ -273,7 +273,7 @@ describe('DashboardPage', () => { describe('FE-PAGE-DASH-014: Archive filter reveals archived trips', () => { it('shows archived trips when the archive filter is selected', async () => { - const oldTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: true }); + const oldTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: 1 }); server.use( http.get('/api/trips', ({ request }) => { const url = new URL(request.url); @@ -414,8 +414,8 @@ describe('DashboardPage', () => { describe('FE-PAGE-DASH-020: Archived section - restore trip', () => { it('clicking restore in archived section moves trip back to active list', async () => { const activeTrip = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10' }); - const archivedTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: true }); - const restoredTrip = { ...archivedTrip, is_archived: false }; + const archivedTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: 1 }); + const restoredTrip = { ...archivedTrip, is_archived: 0 }; server.use( http.get('/api/trips', ({ request }) => { @@ -624,7 +624,7 @@ describe('DashboardPage', () => { describe('FE-PAGE-DASH-027: Archive filter toggles archived trips in and out of view', () => { it('shows archived trips under the archive filter and hides them under planned', async () => { const activeTrip = buildTrip({ title: 'Active Trip', start_date: '2026-08-01', end_date: '2026-08-10' }); - const archivedTrip = buildTrip({ title: 'Old Archived Trip', start_date: '2024-03-01', end_date: '2024-03-07', is_archived: true }); + const archivedTrip = buildTrip({ title: 'Old Archived Trip', start_date: '2024-03-01', end_date: '2024-03-07', is_archived: 1 }); server.use( http.get('/api/trips', ({ request }) => { @@ -660,8 +660,8 @@ describe('DashboardPage', () => { describe('FE-PAGE-DASH-028: Unarchive action restores trip to active list', () => { it('clicking restore on an archived trip removes it from archived section', async () => { const activeTrip = buildTrip({ title: 'My Active Trip', start_date: '2026-08-01', end_date: '2026-08-10' }); - const archivedTrip = buildTrip({ title: 'Restored Trip', start_date: '2024-06-01', end_date: '2024-06-07', is_archived: true }); - const restoredTrip = { ...archivedTrip, is_archived: false }; + const archivedTrip = buildTrip({ title: 'Restored Trip', start_date: '2024-06-01', end_date: '2024-06-07', is_archived: 1 }); + const restoredTrip = { ...archivedTrip, is_archived: 0 }; server.use( http.get('/api/trips', ({ request }) => { diff --git a/client/src/pages/PhotosPage.test.tsx b/client/src/pages/PhotosPage.test.tsx index cf205337..0274c452 100644 --- a/client/src/pages/PhotosPage.test.tsx +++ b/client/src/pages/PhotosPage.test.tsx @@ -25,10 +25,10 @@ function buildPhoto(overrides: Partial = {}): Photo { return { id: 1, trip_id: 1, - filename: 'photo1.jpg', + url: '/uploads/photos/photo1.jpg', original_name: 'photo1.jpg', mime_type: 'image/jpeg', - size: 12345, + file_size: 12345, caption: null, place_id: null, day_id: null, diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 97fee862..674e0b49 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -72,7 +72,7 @@ export function useTripPlanner() { useEffect(() => { addonsApi.enabled().then(data => { - const map = {} + const map: Record = {} data.addons.forEach(a => { map[a.id] = true }) setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab }) if (data.collabFeatures) setCollabFeatures(data.collabFeatures) @@ -413,7 +413,6 @@ export function useTripPlanner() { lng: capturedPlace.lng, address: capturedPlace.address, category_id: capturedPlace.category_id, - icon: capturedPlace.icon, price: capturedPlace.price, }) for (const { dayId, orderIndex } of capturedAssignments) { @@ -444,7 +443,7 @@ export function useTripPlanner() { const newPlace = await tripActions.addPlace(tripId, { name: place.name, description: place.description, lat: place.lat, lng: place.lng, address: place.address, - category_id: place.category_id, icon: place.icon, price: place.price, + category_id: place.category_id, price: place.price, }) for (const a of capturedAssignments.filter(x => x.placeId === place.id)) { await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex) diff --git a/client/src/store/slices/budgetSlice.test.ts b/client/src/store/slices/budgetSlice.test.ts index 371dd682..1549639e 100644 --- a/client/src/store/slices/budgetSlice.test.ts +++ b/client/src/store/slices/budgetSlice.test.ts @@ -125,7 +125,7 @@ describe('budgetSlice', () => { const item = buildBudgetItem({ id: 8, trip_id: 1, - members: [{ user_id: 3, paid: false }], + members: [{ user_id: 3, paid: 0, username: 'carol' }], }); seedStore(useTripStore, { budgetItems: [item] }); diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts index 9f63bc45..b57c4fb9 100644 --- a/client/src/store/slices/budgetSlice.ts +++ b/client/src/store/slices/budgetSlice.ts @@ -2,7 +2,8 @@ import { budgetApi } from '../../api/client' import { budgetRepo } from '../../repo/budgetRepo' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' -import type { BudgetItem, BudgetMember } from '../../types' +import type { BudgetItem, BudgetItemMember } from '../../types' +import type { BudgetCreateItemRequest, BudgetUpdateItemRequest } from '@trek/shared' import { getApiErrorMessage } from '../../types' type SetState = StoreApi['setState'] @@ -13,7 +14,7 @@ export interface BudgetSlice { addBudgetItem: (tripId: number | string, data: Partial) => Promise updateBudgetItem: (tripId: number | string, id: number, data: Partial) => Promise deleteBudgetItem: (tripId: number | string, id: number) => Promise - setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetMember[]; item: BudgetItem }> + setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetItemMember[]; item: BudgetItem }> toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise @@ -31,7 +32,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => addBudgetItem: async (tripId, data) => { try { - const result = await budgetApi.create(tripId, data) + const result = await budgetApi.create(tripId, data as BudgetCreateItemRequest) set(state => ({ budgetItems: [...state.budgetItems, result.item] })) return result.item } catch (err: unknown) { @@ -41,7 +42,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => updateBudgetItem: async (tripId, id, data) => { try { - const result = await budgetApi.update(tripId, id, data) + const result = await budgetApi.update(tripId, id, data as BudgetUpdateItemRequest) set(state => ({ budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item) })) @@ -80,7 +81,10 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => set(state => ({ budgetItems: state.budgetItems.map(item => item.id === itemId - ? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid } : m) } + // The server persists `paid` as 0/1; the optimistic update stores the + // boolean toggle value (truthy-compatible) — narrow it to the member's + // numeric type without changing the stored runtime value. + ? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid: paid as unknown as number } : m) } : item ) })); diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts index 68bc2668..d0918183 100644 --- a/client/src/store/slices/remoteEventHandler.ts +++ b/client/src/store/slices/remoteEventHandler.ts @@ -1,6 +1,6 @@ import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' -import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types' +import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetItemMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types' import { offlineDb } from '../../db/offlineDb' type SetState = StoreApi['setState'] @@ -250,7 +250,7 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket case 'assignment:reordered': { const dayKey = String(payload.dayId) const currentItems = state.assignments[dayKey] || [] - const orderedIds: number[] = payload.orderedIds || [] + const orderedIds: number[] = (payload.orderedIds as number[] | undefined) || [] const reordered = orderedIds.map((id, idx) => { const item = currentItems.find(a => a.id === id) return item ? { ...item, order_index: idx } : null @@ -356,7 +356,7 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket case 'budget:members-updated': return { budgetItems: state.budgetItems.map(i => - i.id === payload.itemId ? { ...i, members: payload.members as BudgetMember[], persons: payload.persons as number } : i + i.id === payload.itemId ? { ...i, members: payload.members as BudgetItemMember[], persons: payload.persons as number } : i ), } case 'budget:member-paid-updated': diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts index 469752e0..15190090 100644 --- a/client/src/store/vacayStore.ts +++ b/client/src/store/vacayStore.ts @@ -35,7 +35,7 @@ interface VacayYearsResponse { interface VacayEntriesResponse { entries: VacayEntry[] - companyHolidays: string[] + companyHolidays: { date: string; note?: string }[] } interface VacayStatsResponse { @@ -109,7 +109,7 @@ interface VacayState { isFused: boolean years: number[] entries: VacayEntry[] - companyHolidays: string[] + companyHolidays: { date: string; note?: string }[] stats: VacayStat[] selectedYear: number selectedUserId: number | null diff --git a/client/src/types.ts b/client/src/types.ts index 4572c558..89bc5991 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -1,4 +1,53 @@ -// Shared types for the TREK travel planner +// Shared types for the TREK travel planner. +// +// Domain entity/response types are now sourced from @trek/shared — the single +// source of truth shared with the server. The Zod schemas there are built to +// match the REAL server response shapes (see shared/src//*.schema.ts, +// each documented against the producing service). Re-exported here so the rest +// of the client keeps importing from '../types' unchanged. +import type { + Trip, + TripMember, + Day, + DayNote, + Place, + AssignmentPlace, + PlaceCategory, + Assignment, + AssignmentParticipant, + PackingItem, + PackingBag, + PackingBagMember, + BudgetItem, + BudgetItemMember, + Reservation, + ReservationEndpoint, + Accommodation, + Tag, + Category, +} from '@trek/shared' + +export type { + Trip, + TripMember, + Day, + DayNote, + Place, + AssignmentPlace, + PlaceCategory, + Assignment, + AssignmentParticipant, + PackingItem, + PackingBag, + PackingBagMember, + BudgetItem, + BudgetItemMember, + Reservation, + ReservationEndpoint, + Accommodation, + Tag, + Category, +} export interface User { id: number @@ -14,85 +63,6 @@ export interface User { must_change_password?: boolean } -export interface Trip { - id: number - name: string - description: string | null - start_date: string - end_date: string - cover_url: string | null - is_archived: boolean - reminder_days: number - owner_id: number - created_at: string - updated_at: string -} - -export interface Day { - id: number - trip_id: number - day_number?: number - date: string - title: string | null - notes: string | null - assignments: Assignment[] - notes_items: DayNote[] -} - -export interface Place { - id: number - trip_id: number - name: string - description: string | null - notes: string | null - lat: number | null - lng: number | null - address: string | null - category_id: number | null - icon: string | null - price: string | null - currency: string | null - image_url: string | null - google_place_id: string | null - osm_id: string | null - route_geometry: string | null - place_time: string | null - end_time: string | null - duration_minutes: number | null - transport_mode: string | null - website: string | null - phone: string | null - created_at: string -} - -export interface Assignment { - id: number - day_id: number - place_id?: number - order_index: number - notes: string | null - place: Place -} - -export interface DayNote { - id: number - day_id: number - text: string - time: string | null - icon: string | null - sort_order?: number - created_at: string -} - -export interface PackingItem { - id: number - trip_id: number - name: string - category: string | null - checked: number - quantity: number -} - export interface TodoItem { id: number trip_id: number @@ -106,82 +76,6 @@ export interface TodoItem { priority: number } -export interface Tag { - id: number - name: string - color: string | null - user_id: number -} - -export interface Category { - id: number - name: string - icon: string | null - user_id: number -} - -export interface BudgetItem { - id: number - trip_id: number - name: string - amount: number - currency: string - category: string | null - paid_by: number | null - persons: number - members: BudgetMember[] - expense_date: string | null -} - -export interface BudgetMember { - user_id: number - paid: boolean -} - -export interface ReservationEndpoint { - id?: number - reservation_id?: number - role: 'from' | 'to' | 'stop' - sequence: number - name: string - code: string | null - lat: number - lng: number - timezone: string | null - local_time: string | null - local_date: string | null -} - -export interface Reservation { - id: number - trip_id: number - name: string - title?: string - type: string - status: 'pending' | 'confirmed' - date: string | null - time: string | null - reservation_time?: string | null - reservation_end_time?: string | null - location?: string | null - confirmation_number: string | null - notes: string | null - url: string | null - day_id?: number | null - end_day_id?: number | null - place_id?: number | null - assignment_id?: number | null - accommodation_id?: number | null - accommodation_start_day_id?: number | null - accommodation_end_day_id?: number | null - day_plan_position?: number | null - day_positions?: Record | null - metadata?: Record | string | null - needs_review?: number - endpoints?: ReservationEndpoint[] - created_at: string -} - export interface TripFile { id: number trip_id: number @@ -200,8 +94,10 @@ export interface TripFile { deleted_at?: string | null created_at: string reservation_title?: string - linked_reservation_ids?: number[] - url?: string + linked_reservation_ids?: (number | null)[] + linked_place_ids?: (number | null)[] + /** Served download path — always present on list/create/update responses (formatFile). */ + url: string } export interface Settings { @@ -271,41 +167,20 @@ export interface UserWithOidc extends User { oidc_issuer?: string | null } -// Accommodation type -export interface Accommodation { - id: number - trip_id: number - name: string - address: string | null - check_in: string | null - check_in_end: string | null - check_out: string | null - confirmation_number: string | null - notes: string | null - url: string | null - created_at: string -} - -// Trip member (owner or collaborator) -export interface TripMember { - id: number - username: string - email?: string - avatar_url?: string | null - role?: string -} - -// Photo type +// Photo type — trip photo as consumed by the PhotosPage / PhotoGallery / +// PhotoLightbox surface (photos table joined with a served `url`). file_size is +// the photos.file_size column; url is the served upload path. export interface Photo { id: number - trip_id: number - filename: string + trip_id?: number + url: string original_name: string - mime_type: string - size: number + mime_type?: string + file_size?: number | null caption: string | null place_id: number | null day_id: number | null + taken_at?: string | null created_at: string } @@ -381,6 +256,8 @@ export interface VacayPlan { block_weekends: boolean carry_over_enabled: boolean company_holidays_enabled: boolean + // Comma-separated weekday indices (e.g. '0,6'); stored as TEXT on vacay_plans. + weekend_days?: string week_start?: number name?: string year?: number @@ -403,10 +280,18 @@ export interface VacayEntry { person_name?: string } +// Vacay per-user stats row as returned by getStats +// (server/src/services/vacayService.ts -> getStats). export interface VacayStat { user_id: number + person_name: string + person_color: string + year: number vacation_days: number + carried_over: number + total_available: number used: number + remaining: number } export interface HolidayInfo { diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index d586f1cb..c656f319 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -79,6 +79,6 @@ export function splitReservationDateTime(value?: string | null): { date: string export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null { const da = assignments[String(dayId)] || [] - const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0) + const total = da.reduce((s, a) => s + (parseFloat(String(a.place?.price ?? '')) || 0), 0) return total > 0 ? `${total.toFixed(0)} ${currency}` : null } diff --git a/client/tests/helpers/factories.ts b/client/tests/helpers/factories.ts index 835518ff..fec8f1a4 100644 --- a/client/tests/helpers/factories.ts +++ b/client/tests/helpers/factories.ts @@ -66,14 +66,15 @@ export function buildTrip(overrides: Partial = {}): Trip { const id = next(); return { id, - name: `Trip ${id}`, + user_id: 1, + title: `Trip ${id}`, description: null, start_date: '2025-06-01', end_date: '2025-06-05', - cover_url: null, - is_archived: false, + currency: 'EUR', + cover_image: null, + is_archived: 0, reminder_days: 7, - owner_id: 1, created_at: '2025-01-01T00:00:00.000Z', updated_at: '2025-01-01T00:00:00.000Z', ...overrides, @@ -105,14 +106,19 @@ export function buildPlace(overrides: Partial = {}): Place { lng: 2.3522, address: null, category_id: null, - icon: null, price: null, + currency: null, image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, + duration_minutes: 60, + notes: null, + transport_mode: 'walking', + website: null, + phone: null, created_at: '2025-01-01T00:00:00.000Z', ...overrides, }; @@ -154,6 +160,7 @@ export function buildPackingItem(overrides: Partial = {}): PackingI name: `Packing item ${id}`, category: null, checked: 0, + sort_order: 0, quantity: 1, ...overrides, }; @@ -181,14 +188,16 @@ export function buildBudgetItem(overrides: Partial = {}): BudgetItem return { id, trip_id: 1, + category: 'Other', name: `Budget item ${id}`, - amount: 100, - currency: 'EUR', - category: null, - paid_by: null, + total_price: 100, persons: 1, + days: null, + note: null, + sort_order: 0, members: [], expense_date: null, + created_at: '2025-01-01T00:00:00.000Z', ...overrides, }; } @@ -198,14 +207,14 @@ export function buildReservation(overrides: Partial = {}): Reservat return { id, trip_id: 1, - name: `Reservation ${id}`, + title: `Reservation ${id}`, type: 'restaurant', status: 'confirmed', - date: null, - time: null, + reservation_time: null, + reservation_end_time: null, + location: null, confirmation_number: null, notes: null, - url: null, created_at: '2025-01-01T00:00:00.000Z', ...overrides, }; @@ -219,6 +228,7 @@ export function buildTripFile(overrides: Partial = {}): TripFile { filename: 'test.pdf', original_name: 'test.pdf', mime_type: 'application/pdf', + url: `/api/trips/1/files/${id}/download`, created_at: '2025-01-01T00:00:00.000Z', ...overrides, }; @@ -240,6 +250,7 @@ export function buildCategory(overrides: Partial = {}): Category { return { id, name: `Category ${id}`, + color: '#6366f1', icon: 'restaurant', user_id: 1, ...overrides, diff --git a/client/tests/helpers/store.ts b/client/tests/helpers/store.ts index 5cb9bc4f..9df8a5dd 100644 --- a/client/tests/helpers/store.ts +++ b/client/tests/helpers/store.ts @@ -25,9 +25,17 @@ export function resetAllStores(): void { usePermissionsStore.setState(initialPermsState, true); } +/** + * Tests routinely seed a store with a partially-populated slice of state, + * including partial nested objects (e.g. only `settings.time_format`). The + * store's own setState wants the exact field types, so seeding accepts a + * deep-partial view and casts at the boundary. + */ +type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T; + export function seedStore( store: { setState: (partial: Partial, replace?: boolean) => void }, - state: Partial, + state: DeepPartial, ): void { - store.setState(state); + store.setState(state as Partial); } diff --git a/client/tests/integration/api/client.test.ts b/client/tests/integration/api/client.test.ts index 11d6a39f..0a6ecd05 100644 --- a/client/tests/integration/api/client.test.ts +++ b/client/tests/integration/api/client.test.ts @@ -623,13 +623,13 @@ describe('API namespace smoke tests', () => { // ── tripsApi additional methods ────────────────────────────────────────────── it('tripsApi.create posts new trip', async () => { - server.use(http.post('/api/trips', () => HttpResponse.json({ id: 1, name: 'Test' }))); - await expect(tripsApi.create({ name: 'Test' })).resolves.toMatchObject({ id: 1 }); + server.use(http.post('/api/trips', () => HttpResponse.json({ id: 1, title: 'Test' }))); + await expect(tripsApi.create({ title: 'Test' })).resolves.toMatchObject({ id: 1 }); }); it('tripsApi.update puts trip data', async () => { server.use(http.put('/api/trips/1', () => HttpResponse.json({ id: 1 }))); - await expect(tripsApi.update(1, { name: 'Updated' })).resolves.toMatchObject({ id: 1 }); + await expect(tripsApi.update(1, { title: 'Updated' })).resolves.toMatchObject({ id: 1 }); }); it('tripsApi.delete deletes a trip', async () => { @@ -765,7 +765,7 @@ describe('API namespace smoke tests', () => { it('reservationsApi.create creates a reservation', async () => { server.use(http.post('/api/trips/1/reservations', () => HttpResponse.json({ id: 1 }))); - await expect(reservationsApi.create(1, { name: 'Hotel' })).resolves.toMatchObject({ id: 1 }); + await expect(reservationsApi.create(1, { title: 'Hotel' })).resolves.toMatchObject({ id: 1 }); }); it('reservationsApi.delete deletes a reservation', async () => { @@ -784,7 +784,7 @@ describe('API namespace smoke tests', () => { it('accommodationsApi.create creates accommodation', async () => { server.use(http.post('/api/trips/1/accommodations', () => HttpResponse.json({ id: 1 }))); - await expect(accommodationsApi.create(1, { name: 'Hotel' })).resolves.toMatchObject({ id: 1 }); + await expect(accommodationsApi.create(1, { place_id: 1, start_day_id: 1, end_day_id: 1 })).resolves.toMatchObject({ id: 1 }); }); it('accommodationsApi.delete deletes accommodation', async () => { diff --git a/client/tests/unit/db/offlineDb.test.ts b/client/tests/unit/db/offlineDb.test.ts index b0645c09..8ec509d8 100644 --- a/client/tests/unit/db/offlineDb.test.ts +++ b/client/tests/unit/db/offlineDb.test.ts @@ -33,14 +33,15 @@ import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, const makeTrip = (id = 1): Trip => ({ id, - name: `Trip ${id}`, + user_id: 42, + title: `Trip ${id}`, description: null, start_date: '2026-07-01', end_date: '2026-07-05', - cover_url: null, - is_archived: false, + currency: 'EUR', + cover_image: null, + is_archived: 0, reminder_days: 3, - owner_id: 42, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }); @@ -65,7 +66,6 @@ const makePlace = (id: number, tripId = 1): Place => ({ lng: 2.3522, address: null, category_id: null, - icon: null, price: null, currency: null, image_url: null, @@ -102,14 +102,14 @@ describe('offlineDb — trips', () => { await upsertTrip(trip); const stored = await offlineDb.trips.get(10); expect(stored).toBeDefined(); - expect(stored!.name).toBe('Trip 10'); + expect(stored!.title).toBe('Trip 10'); }); it('upsertTrip overwrites an existing trip (put semantics)', async () => { await upsertTrip(makeTrip(1)); - await upsertTrip({ ...makeTrip(1), name: 'Updated' }); + await upsertTrip({ ...makeTrip(1), title: 'Updated' }); const stored = await offlineDb.trips.get(1); - expect(stored!.name).toBe('Updated'); + expect(stored!.title).toBe('Updated'); }); }); @@ -133,7 +133,7 @@ describe('offlineDb — places', () => { describe('offlineDb — packing / todo / budget / reservations / files', () => { it('upserts packing items', async () => { - const item: PackingItem = { id: 1, trip_id: 1, name: 'Passport', category: null, checked: 0, quantity: 1 }; + const item: PackingItem = { id: 1, trip_id: 1, name: 'Passport', category: null, checked: 0, sort_order: 0, quantity: 1 }; await upsertPackingItems([item]); expect(await offlineDb.packingItems.count()).toBe(1); }); @@ -149,8 +149,8 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => { it('upserts budget items', async () => { const item: BudgetItem = { - id: 1, trip_id: 1, name: 'Flight', amount: 500, currency: 'EUR', - category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null, + id: 1, trip_id: 1, name: 'Flight', total_price: 500, + category: 'Transport', persons: 1, members: [], expense_date: null, sort_order: 0, }; await upsertBudgetItems([item]); expect(await offlineDb.budgetItems.count()).toBe(1); @@ -158,8 +158,8 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => { it('upserts reservations', async () => { const item: Reservation = { - id: 1, trip_id: 1, name: 'Hotel', type: 'hotel', status: 'confirmed', - date: null, time: null, confirmation_number: null, notes: null, url: null, created_at: '2026-01-01T00:00:00Z', + id: 1, trip_id: 1, title: 'Hotel', type: 'hotel', status: 'confirmed', + reservation_time: null, confirmation_number: null, notes: null, created_at: '2026-01-01T00:00:00Z', }; await upsertReservations([item]); expect(await offlineDb.reservations.count()).toBe(1); @@ -168,7 +168,7 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => { it('upserts trip files', async () => { const file: TripFile = { id: 1, trip_id: 1, filename: 'ticket.pdf', original_name: 'Ticket.pdf', - mime_type: 'application/pdf', created_at: '2026-01-01T00:00:00Z', + mime_type: 'application/pdf', url: '/api/trips/1/files/1/download', created_at: '2026-01-01T00:00:00Z', }; await upsertTripFiles([file]); expect(await offlineDb.tripFiles.count()).toBe(1); @@ -238,7 +238,7 @@ describe('offlineDb — clearTripData', () => { await upsertTrip(makeTrip(1)); await upsertDays([makeDay(1, 1), makeDay(2, 1)]); await upsertPlaces([makePlace(10, 1)]); - const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, quantity: 1 }; + const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, sort_order: 0, quantity: 1 }; await upsertPackingItems([item]); // Also add data for a different trip — should NOT be removed diff --git a/client/tests/unit/remoteEventHandler/budget.test.ts b/client/tests/unit/remoteEventHandler/budget.test.ts index 1effce0b..53e2b3f2 100644 --- a/client/tests/unit/remoteEventHandler/budget.test.ts +++ b/client/tests/unit/remoteEventHandler/budget.test.ts @@ -2,15 +2,15 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useTripStore } from '../../../src/store/tripStore'; import { resetAllStores } from '../../helpers/store'; import { buildBudgetItem } from '../../helpers/factories'; -import type { BudgetMember } from '../../../src/types'; +import type { BudgetItemMember } from '../../../src/types'; beforeEach(() => { resetAllStores(); }); describe('remoteEventHandler > budget', () => { - const member1: BudgetMember = { user_id: 5, paid: false }; - const member2: BudgetMember = { user_id: 6, paid: true }; + const member1: BudgetItemMember = { user_id: 5, paid: 0, username: 'eve' }; + const member2: BudgetItemMember = { user_id: 6, paid: 1, username: 'frank' }; const seedData = () => { useTripStore.setState({ @@ -40,12 +40,12 @@ describe('remoteEventHandler > budget', () => { it('FE-WSEVT-BUDGET-003: budget:updated replaces item in array', () => { seedData(); - const updated = buildBudgetItem({ id: 1, name: 'Updated Hotel', amount: 500 }); + const updated = buildBudgetItem({ id: 1, name: 'Updated Hotel', total_price: 500 }); useTripStore.getState().handleRemoteEvent({ type: 'budget:updated', item: updated }); const { budgetItems } = useTripStore.getState(); const item = budgetItems.find(i => i.id === 1); expect(item?.name).toBe('Updated Hotel'); - expect(item?.amount).toBe(500); + expect(item?.total_price).toBe(500); }); it('FE-WSEVT-BUDGET-004: budget:deleted removes item by ID', () => { @@ -58,7 +58,7 @@ describe('remoteEventHandler > budget', () => { it('FE-WSEVT-BUDGET-005: budget:members-updated replaces entire members array and persons count', () => { seedData(); - const newMembers: BudgetMember[] = [{ user_id: 7, paid: true }, { user_id: 8, paid: false }]; + const newMembers: BudgetItemMember[] = [{ user_id: 7, paid: 1, username: 'grace' }, { user_id: 8, paid: 0, username: 'heidi' }]; useTripStore.getState().handleRemoteEvent({ type: 'budget:members-updated', itemId: 1, @@ -86,8 +86,8 @@ describe('remoteEventHandler > budget', () => { const item = budgetItems.find(i => i.id === 1); const m = item?.members?.find(m => m.user_id === 5); expect(m?.paid).toBe(true); - // Other item members unchanged + // Other item members unchanged (member2 keeps its seeded paid value) const item2 = budgetItems.find(i => i.id === 2); - expect(item2?.members?.[0].paid).toBe(true); + expect(item2?.members?.[0].paid).toBe(1); }); }); diff --git a/client/tests/unit/remoteEventHandler/reservations.test.ts b/client/tests/unit/remoteEventHandler/reservations.test.ts index 718d16e5..df9cc80b 100644 --- a/client/tests/unit/remoteEventHandler/reservations.test.ts +++ b/client/tests/unit/remoteEventHandler/reservations.test.ts @@ -10,13 +10,13 @@ beforeEach(() => { describe('remoteEventHandler > reservations', () => { const seedData = () => { useTripStore.setState({ - reservations: [buildReservation({ id: 1, name: 'Hotel Paris' })], + reservations: [buildReservation({ id: 1, title: 'Hotel Paris' })], }); }; it('FE-WSEVT-RESERV-001: reservation:created prepends new reservation to array', () => { seedData(); - const newRes = buildReservation({ id: 99, name: 'Flight' }); + const newRes = buildReservation({ id: 99, title: 'Flight' }); useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: newRes }); const { reservations } = useTripStore.getState(); expect(reservations).toHaveLength(2); @@ -25,19 +25,19 @@ describe('remoteEventHandler > reservations', () => { it('FE-WSEVT-RESERV-002: reservation:created is idempotent — no duplicate if same ID', () => { seedData(); - const duplicate = buildReservation({ id: 1, name: 'Hotel Paris Dup' }); + const duplicate = buildReservation({ id: 1, title: 'Hotel Paris Dup' }); useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: duplicate }); const { reservations } = useTripStore.getState(); expect(reservations).toHaveLength(1); - expect(reservations[0].name).toBe('Hotel Paris'); + expect(reservations[0].title).toBe('Hotel Paris'); }); it('FE-WSEVT-RESERV-003: reservation:updated replaces reservation in array', () => { seedData(); - const updated = buildReservation({ id: 1, name: 'Hotel Lyon' }); + const updated = buildReservation({ id: 1, title: 'Hotel Lyon' }); useTripStore.getState().handleRemoteEvent({ type: 'reservation:updated', reservation: updated }); const { reservations } = useTripStore.getState(); - expect(reservations[0].name).toBe('Hotel Lyon'); + expect(reservations[0].title).toBe('Hotel Lyon'); }); it('FE-WSEVT-RESERV-004: reservation:deleted removes reservation by ID', () => { @@ -49,8 +49,8 @@ describe('remoteEventHandler > reservations', () => { it('FE-WSEVT-RESERV-005: reservation:created ordering — newest is first', () => { seedData(); - const r2 = buildReservation({ id: 2, name: 'Second' }); - const r3 = buildReservation({ id: 3, name: 'Third' }); + const r2 = buildReservation({ id: 2, title: 'Second' }); + const r3 = buildReservation({ id: 3, title: 'Third' }); useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r2 }); useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r3 }); const { reservations } = useTripStore.getState(); diff --git a/client/tests/unit/remoteEventHandler/trip.test.ts b/client/tests/unit/remoteEventHandler/trip.test.ts index 26f6bdf6..992c260b 100644 --- a/client/tests/unit/remoteEventHandler/trip.test.ts +++ b/client/tests/unit/remoteEventHandler/trip.test.ts @@ -9,21 +9,21 @@ beforeEach(() => { describe('remoteEventHandler > trip', () => { it('FE-WSEVT-TRIP-001: trip:updated replaces trip in state', () => { - const originalTrip = buildTrip({ id: 1, name: 'Paris Trip' }); + const originalTrip = buildTrip({ id: 1, title: 'Paris Trip' }); useTripStore.setState({ trip: originalTrip }); - const updatedTrip = buildTrip({ id: 1, name: 'Paris & Lyon Trip' }); + const updatedTrip = buildTrip({ id: 1, title: 'Paris & Lyon Trip' }); useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip }); const { trip } = useTripStore.getState(); - expect(trip?.name).toBe('Paris & Lyon Trip'); + expect(trip?.title).toBe('Paris & Lyon Trip'); }); it('FE-WSEVT-TRIP-002: trip:updated does not affect other state fields', () => { const existingPlace = buildPlace({ id: 55, name: 'Eiffel Tower' }); useTripStore.setState({ - trip: buildTrip({ id: 1, name: 'Original' }), + trip: buildTrip({ id: 1, title: 'Original' }), places: [existingPlace], }); - const updatedTrip = buildTrip({ id: 1, name: 'Updated' }); + const updatedTrip = buildTrip({ id: 1, title: 'Updated' }); useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip }); const { places } = useTripStore.getState(); expect(places).toHaveLength(1); diff --git a/client/tests/unit/slices/budgetSlice.test.ts b/client/tests/unit/slices/budgetSlice.test.ts index e847dd86..3b428d1b 100644 --- a/client/tests/unit/slices/budgetSlice.test.ts +++ b/client/tests/unit/slices/budgetSlice.test.ts @@ -43,7 +43,7 @@ describe('budgetSlice', () => { const existing = buildBudgetItem({ trip_id: 1 }); seedStore(useTripStore, { budgetItems: [existing] }); - const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel', amount: 200 }); + const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel', total_price: 200 }); expect(result.name).toBe('Hotel'); expect(useTripStore.getState().budgetItems).toHaveLength(2); @@ -64,7 +64,7 @@ describe('budgetSlice', () => { describe('updateBudgetItem', () => { it('FE-BUDGET-004: updateBudgetItem replaces item in array', async () => { - const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', amount: 100 }); + const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', total_price: 100 }); seedStore(useTripStore, { budgetItems: [item] }); server.use( @@ -74,16 +74,16 @@ describe('budgetSlice', () => { }), ); - const result = await useTripStore.getState().updateBudgetItem(1, 10, { name: 'Updated', amount: 150 }); + const result = await useTripStore.getState().updateBudgetItem(1, 10, { name: 'Updated', total_price: 150 }); expect(result.name).toBe('Updated'); expect(useTripStore.getState().budgetItems[0].name).toBe('Updated'); }); it('FE-BUDGET-005: updateBudgetItem with total_price triggers loadReservations when reservation_id present', async () => { - const item = buildBudgetItem({ id: 10, trip_id: 1, amount: 100 }); + const item = buildBudgetItem({ id: 10, trip_id: 1, total_price: 100 }); const initialReservation = buildReservation({ trip_id: 1 }); - const newReservation = buildReservation({ trip_id: 1, name: 'Refreshed Reservation' }); + const newReservation = buildReservation({ trip_id: 1, title: 'Refreshed Reservation' }); seedStore(useTripStore, { budgetItems: [item], reservations: [initialReservation], @@ -106,7 +106,7 @@ describe('budgetSlice', () => { await new Promise(resolve => setTimeout(resolve, 50)); expect(useTripStore.getState().reservations).toHaveLength(1); - expect(useTripStore.getState().reservations[0].name).toBe('Refreshed Reservation'); + expect(useTripStore.getState().reservations[0].title).toBe('Refreshed Reservation'); }); }); @@ -162,7 +162,7 @@ describe('budgetSlice', () => { describe('toggleBudgetMemberPaid', () => { it('FE-BUDGET-008: toggleBudgetMemberPaid updates paid status after API success', async () => { - const member = { user_id: 5, paid: false }; + const member = { user_id: 5, paid: 0, username: 'dave' }; const item = buildBudgetItem({ id: 10, trip_id: 1, members: [member] }); seedStore(useTripStore, { budgetItems: [item] }); diff --git a/client/tests/unit/slices/reservationsSlice.test.ts b/client/tests/unit/slices/reservationsSlice.test.ts index b0b5e134..eea9810d 100644 --- a/client/tests/unit/slices/reservationsSlice.test.ts +++ b/client/tests/unit/slices/reservationsSlice.test.ts @@ -42,20 +42,20 @@ describe('reservationsSlice', () => { describe('addReservation', () => { it('FE-RESERV-002: addReservation prepends to reservations array', async () => { - const existing = buildReservation({ trip_id: 1, name: 'Existing' }); + const existing = buildReservation({ trip_id: 1, title: 'Existing' }); seedStore(useTripStore, { reservations: [existing] }); const result = await useTripStore.getState().addReservation(1, { - name: 'New Hotel', + title: 'New Hotel', type: 'hotel', status: 'pending', }); - expect(result.name).toBe('New Hotel'); + expect(result.title).toBe('New Hotel'); const reservations = useTripStore.getState().reservations; expect(reservations).toHaveLength(2); // addReservation prepends - expect(reservations[0].name).toBe('New Hotel'); + expect(reservations[0].title).toBe('New Hotel'); }); it('FE-RESERV-003: addReservation on failure throws', async () => { @@ -66,14 +66,14 @@ describe('reservationsSlice', () => { ); await expect( - useTripStore.getState().addReservation(1, { name: 'Fail' }) + useTripStore.getState().addReservation(1, { title: 'Fail' }) ).rejects.toThrow(); }); }); describe('updateReservation', () => { it('FE-RESERV-004: updateReservation replaces item in array by id', async () => { - const reservation = buildReservation({ id: 10, trip_id: 1, name: 'Old', status: 'pending' }); + const reservation = buildReservation({ id: 10, trip_id: 1, title: 'Old', status: 'pending' }); seedStore(useTripStore, { reservations: [reservation] }); server.use( @@ -83,10 +83,10 @@ describe('reservationsSlice', () => { }), ); - const result = await useTripStore.getState().updateReservation(1, 10, { name: 'Updated Hotel' }); + const result = await useTripStore.getState().updateReservation(1, 10, { title: 'Updated Hotel' }); - expect(result.name).toBe('Updated Hotel'); - expect(useTripStore.getState().reservations[0].name).toBe('Updated Hotel'); + expect(result.title).toBe('Updated Hotel'); + expect(useTripStore.getState().reservations[0].title).toBe('Updated Hotel'); }); }); diff --git a/client/tests/unit/tripStore.test.ts b/client/tests/unit/tripStore.test.ts index 8d35c9eb..7054a2b0 100644 --- a/client/tests/unit/tripStore.test.ts +++ b/client/tests/unit/tripStore.test.ts @@ -201,14 +201,14 @@ describe('tripStore', () => { describe('updateTrip', () => { it('FE-TRIP-008: updateTrip persists and refreshes trip + days', async () => { - const updatedTrip = buildTrip({ id: 1, name: 'Updated Trip' }); + const updatedTrip = buildTrip({ id: 1, title: 'Updated Trip' }); server.use( http.put('/api/trips/1', () => HttpResponse.json({ trip: updatedTrip })), http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })), ); - const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' }); + const result = await useTripStore.getState().updateTrip(1, { title: 'Updated Trip' }); expect(result).toEqual(updatedTrip); expect(useTripStore.getState().trip).toEqual(updatedTrip); diff --git a/client/tests/unit/utils/formatters.test.ts b/client/tests/unit/utils/formatters.test.ts index a53b926d..96107002 100644 --- a/client/tests/unit/utils/formatters.test.ts +++ b/client/tests/unit/utils/formatters.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../../src/utils/formatters'; +import type { AssignmentsMap } from '../../../src/types'; + +// dayTotalCost intentionally exercises edge-case price inputs (string / non-numeric), +// which are looser than the canonical AssignmentsMap shape — hence the casts below. +const asMap = (m: unknown): AssignmentsMap => m as AssignmentsMap; describe('currencyDecimals', () => { it('returns 0 for zero-decimal currencies', () => { @@ -68,7 +73,7 @@ describe('dayTotalCost', () => { { id: 1, day_id: 1, order_index: 0, notes: null, place: { id: 1, trip_id: 1, name: 'P', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: null, image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } }, ], }; - expect(dayTotalCost(1, assignments, 'EUR')).toBeNull(); + expect(dayTotalCost(1, asMap(assignments), 'EUR')).toBeNull(); }); it('sums prices across assignments', () => { @@ -78,7 +83,7 @@ describe('dayTotalCost', () => { { id: 2, day_id: 1, order_index: 1, notes: null, place: { id: 2, trip_id: 1, name: 'B', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: '30', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } }, ], }; - expect(dayTotalCost(1, assignments, 'EUR')).toBe('50 EUR'); + expect(dayTotalCost(1, asMap(assignments), 'EUR')).toBe('50 EUR'); }); it('ignores non-numeric price strings', () => { @@ -87,7 +92,7 @@ describe('dayTotalCost', () => { { id: 1, day_id: 1, order_index: 0, notes: null, place: { id: 1, trip_id: 1, name: 'A', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: 'free', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } }, ], }; - expect(dayTotalCost(1, assignments, 'EUR')).toBeNull(); + expect(dayTotalCost(1, asMap(assignments), 'EUR')).toBeNull(); }); it('uses the dayId key to look up assignments', () => { @@ -96,7 +101,7 @@ describe('dayTotalCost', () => { { id: 3, day_id: 2, order_index: 0, notes: null, place: { id: 3, trip_id: 1, name: 'C', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: '10', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } }, ], }; - expect(dayTotalCost(1, assignments, 'USD')).toBeNull(); - expect(dayTotalCost(2, assignments, 'USD')).toBe('10 USD'); + expect(dayTotalCost(1, asMap(assignments), 'USD')).toBeNull(); + expect(dayTotalCost(2, asMap(assignments), 'USD')).toBe('10 USD'); }); }); diff --git a/shared/src/assignment/assignment.schema.ts b/shared/src/assignment/assignment.schema.ts index 12da39f6..6c6ac82b 100644 --- a/shared/src/assignment/assignment.schema.ts +++ b/shared/src/assignment/assignment.schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { assignmentPlaceSchema } from '../place/place.schema'; /** * Assignment API contract — single source of truth for the place↔day itinerary @@ -11,6 +12,38 @@ import { z } from 'zod'; * request schemas + the bespoke 404/400 controller messages pin the rest. */ +/** + * Assignment participant embedded on an assignment + * (server/src/services/queryHelpers.ts -> loadParticipantsByAssignmentIds). + */ +export const assignmentParticipantSchema = z.object({ + user_id: z.number(), + username: z.string(), + avatar: z.string().nullable().optional(), +}); +export type AssignmentParticipant = z.infer; + +/** + * Assignment entity as returned by the day/assignment endpoints + * (server/src/services/queryHelpers.ts -> formatAssignmentWithPlace, and + * assignmentService.getAssignmentWithPlace). The embedded `place` is the trimmed + * assignment-place projection, NOT the full place pool entity. `assignment_time` + * /`assignment_end_time` carry the per-assignment override times. + */ +export const assignmentSchema = z.object({ + id: z.number(), + day_id: z.number(), + place_id: z.number(), + order_index: z.number(), + notes: z.string().nullable().optional(), + assignment_time: z.string().nullable().optional(), + assignment_end_time: z.string().nullable().optional(), + participants: z.array(assignmentParticipantSchema).optional(), + created_at: z.string().optional(), + place: assignmentPlaceSchema, +}); +export type Assignment = z.infer; + export const assignmentCreateRequestSchema = z.object({ place_id: z.union([z.number(), z.string()]), notes: z.string().nullable().optional(), diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index d72ba08a..a6c0bc67 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -12,6 +12,44 @@ import { z } from 'zod'; * linked reservation's metadata (and broadcasts reservation:updated). */ +/** + * Budget item member as embedded on a budget item + * (server/src/services/budgetService.ts -> loadItemMembers). `paid` is the raw + * SQLite INTEGER (0/1); `avatar_url` is the resolved avatar (avatarUrl()). + */ +export const budgetItemMemberSchema = z.object({ + user_id: z.number(), + paid: z.number(), + username: z.string(), + avatar_url: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), + budget_item_id: z.number().optional(), +}); +export type BudgetItemMember = z.infer; + +/** + * Budget item entity as returned by the budget list/create/update endpoints + * (server/src/services/budgetService.ts). Columns of the `budget_items` table + * plus the embedded `members` array. total_price is SQLite REAL. + */ +export const budgetItemSchema = z.object({ + id: z.number(), + trip_id: z.number(), + category: z.string(), + name: z.string(), + total_price: z.number(), + persons: z.number().nullable().optional(), + days: z.number().nullable().optional(), + note: z.string().nullable().optional(), + reservation_id: z.number().nullable().optional(), + paid_by_user_id: z.number().nullable().optional(), + expense_date: z.string().nullable().optional(), + sort_order: z.number().optional(), + created_at: z.string().optional(), + members: z.array(budgetItemMemberSchema).optional(), +}); +export type BudgetItem = z.infer; + export const budgetCreateItemRequestSchema = z.object({ name: z.string().min(1), category: z.string().optional(), diff --git a/shared/src/day/day.schema.ts b/shared/src/day/day.schema.ts index 6e691a7e..fafc271a 100644 --- a/shared/src/day/day.schema.ts +++ b/shared/src/day/day.schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { assignmentSchema } from '../assignment/assignment.schema'; /** * Day + day-note API contract — single source of truth for the @@ -11,6 +12,39 @@ import { z } from 'zod'; * (the legacy validateStringLengths middleware) — reproduced in the controller. */ +/** + * Day note entity (server day_notes table / dayNoteService). `sort_order` is + * SQLite REAL; `icon` defaults to a note emoji. + */ +export const dayNoteSchema = z.object({ + id: z.number(), + day_id: z.number(), + trip_id: z.number().optional(), + text: z.string(), + time: z.string().nullable().optional(), + icon: z.string().nullable().optional(), + sort_order: z.number().optional(), + created_at: z.string().optional(), +}); +export type DayNote = z.infer; + +/** + * Day entity as returned by the day list/get endpoints + * (server/src/services/dayService.ts -> listDays). Columns of the `days` table + * plus the embedded `assignments` and `notes_items` arrays. + */ +export const daySchema = z.object({ + id: z.number(), + trip_id: z.number(), + day_number: z.number().optional(), + date: z.string().nullable().optional(), + title: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + assignments: z.array(assignmentSchema).optional(), + notes_items: z.array(dayNoteSchema).optional(), +}); +export type Day = z.infer; + export const dayCreateRequestSchema = z.object({ date: z.string().optional(), notes: z.string().optional(), diff --git a/shared/src/packing/packing.schema.ts b/shared/src/packing/packing.schema.ts index f7f1b329..2c7e87ff 100644 --- a/shared/src/packing/packing.schema.ts +++ b/shared/src/packing/packing.schema.ts @@ -13,6 +13,56 @@ import { z } from 'zod'; const open = z.record(z.string(), z.unknown()); +/** + * Packing item entity as returned by the packing endpoints + * (server/src/services/packingService.ts -> SELECT * FROM packing_items). + * `checked` is the raw SQLite INTEGER (0/1). Columns match the packing_items + * table (see server DB): weight_grams/bag_id are nullable, quantity defaults 1. + */ +export const packingItemSchema = z.object({ + id: z.number(), + trip_id: z.number(), + name: z.string(), + checked: z.number(), + category: z.string().nullable().optional(), + sort_order: z.number(), + weight_grams: z.number().nullable().optional(), + bag_id: z.number().nullable().optional(), + quantity: z.number().optional(), + created_at: z.string().optional(), +}); +export type PackingItem = z.infer; + +/** + * Packing bag member embedded on a bag (server packingService -> listBags). + * `avatar` is the resolved avatar URL. + */ +export const packingBagMemberSchema = z.object({ + user_id: z.number(), + username: z.string(), + avatar: z.string().nullable().optional(), +}); +export type PackingBagMember = z.infer; + +/** + * Packing bag entity (server packingService -> listBags). Columns of the + * packing_bags table plus the embedded `members` array (and the optional + * `assigned_username` join present on updateBag). + */ +export const packingBagSchema = z.object({ + id: z.number(), + trip_id: z.number(), + name: z.string(), + color: z.string(), + weight_limit_grams: z.number().nullable().optional(), + sort_order: z.number(), + user_id: z.number().nullable().optional(), + assigned_username: z.string().nullable().optional(), + created_at: z.string().optional(), + members: z.array(packingBagMemberSchema).optional(), +}); +export type PackingBag = z.infer; + export const packingCreateItemRequestSchema = z.object({ name: z.string().min(1), category: z.string().optional(), diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index e91e6beb..beb62598 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -1,4 +1,6 @@ import { z } from 'zod'; +import { categorySchema } from '../category/category.schema'; +import { tagSchema } from '../tag/tag.schema'; /** * Place API contract — single source of truth for the /api/trips/:tripId/places @@ -14,6 +16,90 @@ import { z } from 'zod'; const open = z.record(z.string(), z.unknown()); +/** + * Embedded category as returned on a place — a trimmed projection of the + * categories row (id/name/color/icon), built inline by placeService and + * getPlaceWithTags. `null` when the place has no category_id. + */ +export const placeCategorySchema = z + .object({ + id: z.number(), + name: z.string().nullable(), + color: z.string().nullable(), + icon: z.string().nullable(), + }) + .nullable(); +export type PlaceCategory = z.infer; + +/** + * Full place entity as returned by the place list / get / create / update + * endpoints (server/src/services/placeService.ts -> getPlaceWithTags). All + * columns of the `places` table (see server/data DB) plus the joined `category` + * projection and `tags` array. Numbers (lat/lng/price) are SQLite REAL, ids are + * INTEGER; provider-derived columns are nullable. + */ +export const placeSchema = z.object({ + id: z.number(), + trip_id: z.number(), + name: z.string(), + description: z.string().nullable().optional(), + lat: z.number().nullable().optional(), + lng: z.number().nullable().optional(), + address: z.string().nullable().optional(), + category_id: z.number().nullable().optional(), + price: z.number().nullable().optional(), + currency: z.string().nullable().optional(), + reservation_status: z.string().nullable().optional(), + reservation_notes: z.string().nullable().optional(), + reservation_datetime: z.string().nullable().optional(), + place_time: z.string().nullable().optional(), + end_time: z.string().nullable().optional(), + duration_minutes: z.number().nullable().optional(), + notes: z.string().nullable().optional(), + image_url: z.string().nullable().optional(), + google_place_id: z.string().nullable().optional(), + osm_id: z.string().nullable().optional(), + route_geometry: z.string().nullable().optional(), + website: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + transport_mode: z.string().nullable().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), + category: placeCategorySchema.optional(), + tags: z.array(tagSchema.partial()).optional(), +}); +export type Place = z.infer; + +/** + * Trimmed place projection embedded inside a day-assignment response + * (server/src/services/queryHelpers.ts -> formatAssignmentWithPlace). This is a + * SUBSET of the full place: no trip_id / osm_id / route_geometry / created_at / + * reservation_* — only the fields the planner needs to render the itinerary card. + */ +export const assignmentPlaceSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string().nullable().optional(), + lat: z.number().nullable().optional(), + lng: z.number().nullable().optional(), + address: z.string().nullable().optional(), + category_id: z.number().nullable().optional(), + price: z.number().nullable().optional(), + currency: z.string().nullable().optional(), + place_time: z.string().nullable().optional(), + end_time: z.string().nullable().optional(), + duration_minutes: z.number().nullable().optional(), + notes: z.string().nullable().optional(), + image_url: z.string().nullable().optional(), + transport_mode: z.string().nullable().optional(), + google_place_id: z.string().nullable().optional(), + website: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + category: placeCategorySchema.optional(), + tags: z.array(tagSchema.partial()).optional(), +}); +export type AssignmentPlace = z.infer; + export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) })); export type PlaceCreateRequest = z.infer; diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts index 283a0f08..7655d95a 100644 --- a/shared/src/reservation/reservation.schema.ts +++ b/shared/src/reservation/reservation.schema.ts @@ -15,6 +15,91 @@ import { z } from 'zod'; const open = z.record(z.string(), z.unknown()); +/** + * A reservation endpoint (flight/train leg terminal) — row of the + * reservation_endpoints table (server/src/services/reservationService.ts). + */ +export const reservationEndpointSchema = z.object({ + id: z.number().optional(), + reservation_id: z.number().optional(), + role: z.enum(['from', 'to', 'stop']), + sequence: z.number(), + name: z.string(), + code: z.string().nullable(), + lat: z.number(), + lng: z.number(), + timezone: z.string().nullable(), + local_time: z.string().nullable(), + local_date: z.string().nullable(), +}); +export type ReservationEndpoint = z.infer; + +/** + * Reservation entity as returned by the reservation list endpoint + * (server/src/services/reservationService.ts -> listReservations). Columns of + * the `reservations` table plus the joined day_number / place_name / linked + * accommodation fields and the computed `day_positions` + `endpoints`. + * `accommodation_id` is stored as TEXT in the DB. + */ +export const reservationSchema = z.object({ + id: z.number(), + trip_id: z.number(), + day_id: z.number().nullable().optional(), + end_day_id: z.number().nullable().optional(), + place_id: z.number().nullable().optional(), + assignment_id: z.number().nullable().optional(), + title: z.string(), + reservation_time: z.string().nullable().optional(), + reservation_end_time: z.string().nullable().optional(), + location: z.string().nullable().optional(), + confirmation_number: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + status: z.string(), + type: z.string(), + accommodation_id: z.union([z.number(), z.string()]).nullable().optional(), + metadata: z.string().nullable().optional(), + needs_review: z.number().optional(), + day_plan_position: z.number().nullable().optional(), + created_at: z.string().optional(), + // joined / computed in listReservations + day_number: z.number().nullable().optional(), + place_name: z.string().nullable().optional(), + accommodation_place_id: z.number().nullable().optional(), + accommodation_name: z.string().nullable().optional(), + accommodation_start_day_id: z.number().nullable().optional(), + accommodation_end_day_id: z.number().nullable().optional(), + day_positions: z.record(z.string(), z.number()).nullable().optional(), + endpoints: z.array(reservationEndpointSchema).optional(), +}); +export type Reservation = z.infer; + +/** + * Accommodation entity as returned by listAccommodations / getAccommodationWithPlace + * (server/src/services/dayService.ts). Columns of the day_accommodations table + * plus the joined place fields and (on list) the linked reservation_title. + */ +export const accommodationSchema = z.object({ + id: z.number(), + trip_id: z.number(), + place_id: z.number().nullable().optional(), + start_day_id: z.number(), + end_day_id: z.number(), + check_in: z.string().nullable().optional(), + check_in_end: z.string().nullable().optional(), + check_out: z.string().nullable().optional(), + confirmation: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + created_at: z.string().optional(), + // joined in listAccommodations / getAccommodationWithPlace + place_name: z.string().nullable().optional(), + place_address: z.string().nullable().optional(), + place_image: z.string().nullable().optional(), + place_lat: z.number().nullable().optional(), + place_lng: z.number().nullable().optional(), + reservation_title: z.string().nullable().optional(), +}); +export type Accommodation = z.infer; + /** Reservation create: title is required; the many optional fields stay open. */ export const reservationCreateRequestSchema = open.and(z.object({ title: z.string().min(1) })); export type ReservationCreateRequest = z.infer; diff --git a/shared/src/trip/trip.schema.ts b/shared/src/trip/trip.schema.ts index 4c86bc57..1e1db07d 100644 --- a/shared/src/trip/trip.schema.ts +++ b/shared/src/trip/trip.schema.ts @@ -13,6 +13,51 @@ import { z } from 'zod'; * permission checks + audit logging. Trip rows are wide, so responses stay open. */ +/** + * Trip entity as returned by the trip list / get / create / update endpoints + * (server/src/services/tripService.ts -> TRIP_SELECT). Columns of the `trips` + * table plus the computed list fields (day_count, place_count, is_owner as 0/1, + * owner_username, shared_count). `is_archived` is the raw SQLite INTEGER. + */ +export const tripSchema = z.object({ + id: z.number(), + user_id: z.number(), + title: z.string(), + description: z.string().nullable().optional(), + start_date: z.string().nullable().optional(), + end_date: z.string().nullable().optional(), + currency: z.string(), + cover_image: z.string().nullable().optional(), + is_archived: z.number(), + reminder_days: z.number(), + created_at: z.string().optional(), + updated_at: z.string().optional(), + // computed in TRIP_SELECT (list/get) + day_count: z.number().optional(), + place_count: z.number().optional(), + is_owner: z.number().optional(), + owner_username: z.string().optional(), + shared_count: z.number().optional(), +}); +export type Trip = z.infer; + +/** + * Trip member as returned by the members endpoint + * (server/src/services/tripService.ts -> listMembers). Owner + collaborators + * share this shape; `avatar_url` is resolved from the stored avatar. + */ +export const tripMemberSchema = z.object({ + id: z.number(), + username: z.string(), + email: z.string().optional(), + avatar: z.string().nullable().optional(), + avatar_url: z.string().nullable().optional(), + role: z.string().optional(), + added_at: z.string().nullable().optional(), + invited_by_username: z.string().nullable().optional(), +}); +export type TripMember = z.infer; + export const tripCreateRequestSchema = z.object({ title: z.string().min(1), description: z.string().nullable().optional(),