Derive client domain types from the shared schema contracts

Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.
This commit is contained in:
Maurice
2026-05-31 15:42:39 +02:00
parent 239a68bb48
commit 3977a5ecba
52 changed files with 732 additions and 435 deletions
@@ -66,7 +66,8 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Transport');
// 'Transport' appears in the category section header and the spend breakdown chart.
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
@@ -76,7 +77,8 @@ describe('BudgetPanel', () => {
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Name');
await screen.findByText('Total');
// 'Total' appears both as a table header and in the chart total label.
expect((await screen.findAllByText('Total')).length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
@@ -169,8 +171,9 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Transport');
await screen.findByText('Hotels');
// Each category appears in its section header and again in the breakdown chart.
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
expect((await screen.findAllByText('Hotels')).length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
@@ -200,7 +203,8 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('ToDelete');
// 'ToDelete' appears in the category header and the breakdown chart.
expect((await screen.findAllByText('ToDelete')).length).toBeGreaterThan(0);
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
});
@@ -390,7 +394,7 @@ describe('BudgetPanel', () => {
const item = {
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
total_price: 75,
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }],
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: 0 }],
};
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
@@ -425,7 +429,7 @@ describe('BudgetPanel', () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
// Use a user with id != 1 so they're not the owner
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
@@ -439,7 +443,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
@@ -484,7 +488,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
+6 -3
View File
@@ -19,6 +19,7 @@ interface NoteFile {
filename: string
original_name: string
mime_type: string
file_size?: number | null
url?: string
}
@@ -39,6 +40,8 @@ interface CollabNote {
author?: { username: string; avatar: string | null }
user?: { username: string; avatar: string | null }
files?: NoteFile[]
// Wire field: collabService embeds note files as `attachments` (with url).
attachments?: NoteFile[]
}
interface NoteAuthor {
@@ -180,7 +183,7 @@ const formatTimestamp = (ts, t, locale) => {
if (!ts) return ''
const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z')
const now = new Date()
const diffMs = now - d
const diffMs = now.getTime() - d.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return t('collab.chat.justNow') || 'just now'
if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago`
@@ -240,7 +243,7 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) {
// ── New Note Modal (portal to body) ─────────────────────────────────────────
interface NoteFormModalProps {
onClose: () => void
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
onSubmit: (data: { title: string; content: string; category: string | null; website: string | null; color?: string | null; _pendingFiles?: File[]; files?: File[] }) => Promise<void>
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
existingCategories: string[]
categoryColors: Record<string, string>
@@ -849,7 +852,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
)}
</div>
{/* Right: website + attachment thumbnails */}
{(note.website || note.attachments?.length > 0) && (
{(note.website || (note.attachments?.length ?? 0) > 0) && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
{/* Website */}
{note.website && (
+2 -2
View File
@@ -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,
@@ -32,22 +32,23 @@ function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}
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 })],
},
@@ -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> = {}): 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(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
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(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
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(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
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 });
+1 -1
View File
@@ -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'}>
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
@@ -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<string, unknown> = {}) {
function buildNotification(overrides: Partial<InAppNotification> = {}): InAppNotification {
return {
id: _notifId++,
type: 'simple',
@@ -20,15 +21,15 @@ function buildNotification(overrides: Record<string, unknown> = {}) {
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(<InAppNotificationBell />);
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(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await screen.findByText('Notifications');
+2 -2
View File
@@ -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<T extends Waypoint>(places: T[]): T[] {
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length <= 2) return places
const visited = new Set<number>()
const result: Waypoint[] = []
const result: T[] = []
let current = valid[0]
visited.add(0)
result.push(current)
@@ -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> = {}): 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(<InAppNotificationItem notification={buildNotification({ is_read: 0 })} />);
render(<InAppNotificationItem notification={buildNotification({ is_read: false })} />);
expect(screen.getByTitle('Mark as read')).toBeInTheDocument();
});
it('FE-COMP-NOTIF-006: does not show Mark as read button for read notification', () => {
render(<InAppNotificationItem notification={buildNotification({ is_read: 1 })} />);
render(<InAppNotificationItem notification={buildNotification({ is_read: true })} />);
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(<InAppNotificationItem notification={buildNotification({ id: 42, is_read: 0 })} />);
render(<InAppNotificationItem notification={buildNotification({ id: 42, is_read: false })} />);
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}
/>
+4 -3
View File
@@ -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, string | number>) => 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)
@@ -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<string, PackingItem[]> = {}
for (const item of filtered) {
const kat = item.category || t('packing.defaultCategory')
if (!groups[kat]) groups[kat] = []
@@ -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(),
}
@@ -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(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place],
@@ -1553,7 +1553,7 @@ describe('DayPlanSidebar', () => {
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(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [placeA, placeB],
@@ -31,7 +31,7 @@ import {
import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip'
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult, RouteSegment } from '../../types'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment } from '../../types'
const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText },
@@ -169,7 +169,7 @@ interface DayPlanSidebarProps {
onSelectDay: (dayId: number | null) => 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<number>(JSON.parse(saved) as number[])
} catch {}
return new Set(days.map(d => d.id))
return new Set<number>(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 (
<div style={{
@@ -23,6 +23,10 @@ interface PlaceFormData {
notes: string
transport_mode: string
website: string
// Populated from a maps-search pick (not part of the initial blank form).
phone?: string
google_place_id?: string
osm_id?: string
}
function isGoogleMapsUrl(input: string): boolean {
@@ -235,7 +235,7 @@ describe('PlaceInspector', () => {
});
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(
<PlaceInspector
{...defaultProps}
@@ -250,7 +250,7 @@ describe('PlaceInspector', () => {
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(
<PlaceInspector
{...defaultProps}
@@ -406,7 +406,7 @@ describe('PlaceInspector', () => {
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(
<PlaceInspector
{...defaultProps}
@@ -423,7 +423,7 @@ describe('PlaceInspector', () => {
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(
<PlaceInspector
{...defaultProps}
@@ -534,7 +534,7 @@ describe('PlaceInspector', () => {
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
@@ -104,6 +104,7 @@ function formatFileSize(bytes) {
interface TripMember {
id: number
username: string
avatar?: string | null
avatar_url?: string | null
}
@@ -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 (
<Modal
@@ -13,7 +13,9 @@ import type { Trip } from '../../types'
interface TripFormModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
// Create returns the new trip (so we can attach members / upload the cover);
// update resolves without a payload.
onSave: (data: Record<string, string | number | null>) => 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
}
@@ -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(<TripMembersModal {...defaultProps} />);
@@ -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(<TripMembersModal {...defaultProps} />);
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<string, unknown> | 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(
@@ -36,7 +36,7 @@ export default function VacayCalendar() {
}, [selectedYear])
const companyHolidaySet = useMemo(() => {
const s = new Set()
const s = new Set<string>()
companyHolidays.forEach(h => s.add(h.date))
return s
}, [companyHolidays])
@@ -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(<VacayMonthCard {...props} />)
// 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(<VacayMonthCard {...props} />)
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(<VacayMonthCard {...props} />)
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(<VacayMonthCard {...props} />)
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(<VacayMonthCard {...props} />)
@@ -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' },
],
},
}
+7 -11
View File
@@ -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<void>
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<number | string>(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)
}
@@ -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
+16 -16
View File
@@ -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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('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<HTMLElement>('div')!.querySelector('input')!;
fireEvent.change(clientSecretInput, { target: { value: 'my-client-secret' } });
// Verify the inputs updated
+7 -7
View File
@@ -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 }) => {
+2 -2
View File
@@ -25,10 +25,10 @@ function buildPhoto(overrides: Partial<Photo> = {}): 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,
@@ -72,7 +72,7 @@ export function useTripPlanner() {
useEffect(() => {
addonsApi.enabled().then(data => {
const map = {}
const map: Record<string, boolean> = {}
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)
+1 -1
View File
@@ -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] });
+9 -5
View File
@@ -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<TripStoreState>['setState']
@@ -13,7 +14,7 @@ export interface BudgetSlice {
addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem>
updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem>
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
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<void>
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void>
@@ -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
)
}));
@@ -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<TripStoreState>['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':
+2 -2
View File
@@ -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
+72 -187
View File
@@ -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/<domain>/*.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<number, number> | null
metadata?: Record<string, string> | 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 {
+1 -1
View File
@@ -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
}
+24 -13
View File
@@ -66,14 +66,15 @@ export function buildTrip(overrides: Partial<Trip> = {}): 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> = {}): 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<PackingItem> = {}): 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> = {}): 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<Reservation> = {}): 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> = {}): 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> = {}): Category {
return {
id,
name: `Category ${id}`,
color: '#6366f1',
icon: 'restaurant',
user_id: 1,
...overrides,
+10 -2
View File
@@ -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> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]> } : T;
export function seedStore<T extends object>(
store: { setState: (partial: Partial<T>, replace?: boolean) => void },
state: Partial<T>,
state: DeepPartial<T>,
): void {
store.setState(state);
store.setState(state as Partial<T>);
}
+5 -5
View File
@@ -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 () => {
+15 -15
View File
@@ -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
@@ -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);
});
});
@@ -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();
@@ -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);
+7 -7
View File
@@ -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] });
@@ -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');
});
});
+2 -2
View File
@@ -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);
+10 -5
View File
@@ -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');
});
});
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { assignmentPlaceSchema } from '../place/place.schema';
/**
* Assignment API contract single source of truth for the placeday 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<typeof assignmentParticipantSchema>;
/**
* 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<typeof assignmentSchema>;
export const assignmentCreateRequestSchema = z.object({
place_id: z.union([z.number(), z.string()]),
notes: z.string().nullable().optional(),
+38
View File
@@ -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<typeof budgetItemMemberSchema>;
/**
* 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<typeof budgetItemSchema>;
export const budgetCreateItemRequestSchema = z.object({
name: z.string().min(1),
category: z.string().optional(),
+34
View File
@@ -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<typeof dayNoteSchema>;
/**
* 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<typeof daySchema>;
export const dayCreateRequestSchema = z.object({
date: z.string().optional(),
notes: z.string().optional(),
+50
View File
@@ -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<typeof packingItemSchema>;
/**
* 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<typeof packingBagMemberSchema>;
/**
* 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<typeof packingBagSchema>;
export const packingCreateItemRequestSchema = z.object({
name: z.string().min(1),
category: z.string().optional(),
+86
View File
@@ -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<typeof placeCategorySchema>;
/**
* 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<typeof placeSchema>;
/**
* 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<typeof assignmentPlaceSchema>;
export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) }));
export type PlaceCreateRequest = z.infer<typeof placeCreateRequestSchema>;
@@ -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<typeof reservationEndpointSchema>;
/**
* 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<typeof reservationSchema>;
/**
* 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<typeof accommodationSchema>;
/** 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<typeof reservationCreateRequestSchema>;
+45
View File
@@ -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<typeof tripSchema>;
/**
* 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<typeof tripMemberSchema>;
export const tripCreateRequestSchema = z.object({
title: z.string().min(1),
description: z.string().nullable().optional(),