{/* 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(),