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] })) http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
); );
render(<BudgetPanel tripId={1} />); 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 () => { it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
@@ -76,7 +77,8 @@ describe('BudgetPanel', () => {
); );
render(<BudgetPanel tripId={1} />); render(<BudgetPanel tripId={1} />);
await screen.findByText('Name'); 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 () => { 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] })) http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
); );
render(<BudgetPanel tripId={1} />); render(<BudgetPanel tripId={1} />);
await screen.findByText('Transport'); // Each category appears in its section header and again in the breakdown chart.
await screen.findByText('Hotels'); 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 () => { 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] })) http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
); );
render(<BudgetPanel tripId={1} />); 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(); expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
}); });
@@ -390,7 +394,7 @@ describe('BudgetPanel', () => {
const item = { const item = {
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }), ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
total_price: 75, 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( server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
@@ -425,7 +429,7 @@ describe('BudgetPanel', () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } }); seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
// Use a user with id != 1 so they're not the owner // Use a user with id != 1 so they're not the owner
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); 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 }; const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
server.use( server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) 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 () => { it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } }); seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); 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' }; const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
server.use( server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) 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 () => { 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(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); 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 }; const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
server.use( server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
+6 -3
View File
@@ -19,6 +19,7 @@ interface NoteFile {
filename: string filename: string
original_name: string original_name: string
mime_type: string mime_type: string
file_size?: number | null
url?: string url?: string
} }
@@ -39,6 +40,8 @@ interface CollabNote {
author?: { username: string; avatar: string | null } author?: { username: string; avatar: string | null }
user?: { username: string; avatar: string | null } user?: { username: string; avatar: string | null }
files?: NoteFile[] files?: NoteFile[]
// Wire field: collabService embeds note files as `attachments` (with url).
attachments?: NoteFile[]
} }
interface NoteAuthor { interface NoteAuthor {
@@ -180,7 +183,7 @@ const formatTimestamp = (ts, t, locale) => {
if (!ts) return '' if (!ts) return ''
const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z') const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z')
const now = new Date() const now = new Date()
const diffMs = now - d const diffMs = now.getTime() - d.getTime()
const diffMins = Math.floor(diffMs / 60000) const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return t('collab.chat.justNow') || 'just now' if (diffMins < 1) return t('collab.chat.justNow') || 'just now'
if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago` 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) ───────────────────────────────────────── // ── New Note Modal (portal to body) ─────────────────────────────────────────
interface NoteFormModalProps { interface NoteFormModalProps {
onClose: () => void 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> onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
existingCategories: string[] existingCategories: string[]
categoryColors: Record<string, string> categoryColors: Record<string, string>
@@ -849,7 +852,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
)} )}
</div> </div>
{/* Right: website + attachment thumbnails */} {/* 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' }}> <div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
{/* Website */} {/* Website */}
{note.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 { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react' import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
@@ -17,7 +17,7 @@ function useIsDesktop(breakpoint = 1024) {
return isDesktop return isDesktop
} }
const card = { const card: CSSProperties = {
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)', background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)',
overflow: 'hidden', minHeight: 0, overflow: 'hidden', minHeight: 0,
@@ -32,22 +32,23 @@ function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}
notes: null, notes: null,
place: { place: {
id, id,
trip_id: 1,
name: `Place ${id}`, name: `Place ${id}`,
description: null, description: null,
lat: 0, lat: 0,
lng: 0, lng: 0,
address: null, address: null,
category_id: null, category_id: null,
icon: null,
price: null, price: null,
currency: null,
image_url: null, image_url: null,
google_place_id: null, google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null, place_time: null,
end_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, ...placeOverrides,
}, },
participants, participants,
@@ -83,7 +84,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => { it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(10, { place_time: '08:00' })], '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', () => { it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(20, { name: 'Eiffel Tower' })], '1': [makeAssignment(20, { name: 'Eiffel Tower' })],
}, },
@@ -106,7 +107,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => { it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(21, { name: 'Museum' })], '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', () => { it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })], '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', () => { it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h' } }) seedStore(useSettingsStore, { settings: { time_format: '24h' } })
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })], '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', () => { it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
seedStore(useSettingsStore, { settings: { time_format: '12h' } }) seedStore(useSettingsStore, { settings: { time_format: '12h' } })
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })], '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', () => { it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })], '1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
}, },
@@ -164,7 +165,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => { it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })], '1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
}, },
@@ -179,7 +180,7 @@ describe('WhatsNextWidget', () => {
trip_id: 1, trip_id: 1,
date: getFutureDate(i + 1), date: getFutureDate(i + 1),
title: null, title: null,
order: i, day_number: i,
assignments: [], assignments: [],
notes_items: [], notes_items: [],
notes: null, notes: null,
@@ -207,7 +208,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => { it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])], '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', () => { it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(41, { name: 'Park' }, [])], '1': [makeAssignment(41, { name: 'Park' }, [])],
}, },
@@ -229,7 +230,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => { it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })], '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', () => { it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
seedStore(useTripStore, { 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: { assignments: {
'1': [ '1': [
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }), makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
@@ -263,7 +264,7 @@ describe('WhatsNextWidget', () => {
if (now.getHours() > 0) { if (now.getHours() > 0) {
const pastTime = '00:01' // Very early — will be past for most of the day const pastTime = '00:01' // Very early — will be past for most of the day
seedStore(useTripStore, { 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: { assignments: {
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })], '1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
}, },
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories'; import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import type { TripFile } from '../../types';
import FileManager from './FileManager'; import FileManager from './FileManager';
// Mock getAuthUrl // Mock getAuthUrl
@@ -36,20 +37,21 @@ vi.mock('../../api/client', async (importOriginal) => {
import { filesApi } from '../../api/client'; import { filesApi } from '../../api/client';
const buildFile = (overrides = {}) => ({ const buildFile = (overrides: Partial<TripFile> = {}): TripFile => ({
id: 1, id: 1,
trip_id: 1,
filename: 'report.pdf',
original_name: 'report.pdf', original_name: 'report.pdf',
mime_type: 'application/pdf', mime_type: 'application/pdf',
file_size: 51200, file_size: 51200,
created_at: '2025-01-10T08:00:00Z', created_at: '2025-01-10T08:00:00Z',
url: '/uploads/trips/1/report.pdf', url: '/uploads/trips/1/report.pdf',
starred: false, starred: 0,
deleted_at: null, deleted_at: null,
place_id: null, place_id: null,
reservation_id: null, reservation_id: null,
day_id: null,
uploaded_by: 1, uploaded_by: 1,
uploader_name: 'Alice', uploaded_by_name: 'Alice',
...overrides, ...overrides,
}); });
@@ -388,7 +390,7 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => { it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories'); 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]} />); render(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
const user = userEvent.setup(); 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 () => { it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories'); 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 }); const file = buildFile({ id: 1 });
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />); render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
const user = userEvent.setup(); 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 () => { 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 { buildPlace, buildReservation } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Notre Dame' }); 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]} />); render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
const user = userEvent.setup(); const user = userEvent.setup();
@@ -527,7 +529,7 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => { it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories'); 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 // File already has reservation_id set to 20
const file = buildFile({ id: 1, reservation_id: 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)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}> onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} /> <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)' }} />} {isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button> </button>
) )
@@ -7,9 +7,10 @@ import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories'; import { buildUser } from '../../../tests/helpers/factories';
import InAppNotificationBell from './InAppNotificationBell'; import InAppNotificationBell from './InAppNotificationBell';
import type { InAppNotification } from '../../store/inAppNotificationStore';
let _notifId = 1; let _notifId = 1;
function buildNotification(overrides: Record<string, unknown> = {}) { function buildNotification(overrides: Partial<InAppNotification> = {}): InAppNotification {
return { return {
id: _notifId++, id: _notifId++,
type: 'simple', type: 'simple',
@@ -20,15 +21,15 @@ function buildNotification(overrides: Record<string, unknown> = {}) {
sender_avatar: null, sender_avatar: null,
recipient_id: 1, recipient_id: 1,
title_key: 'test', title_key: 'test',
title_params: '{}', title_params: {},
text_key: 'test.text', text_key: 'test.text',
text_params: '{}', text_params: {},
positive_text_key: null, positive_text_key: null,
negative_text_key: null, negative_text_key: null,
response: null, response: null,
navigate_text_key: null, navigate_text_key: null,
navigate_target: null, navigate_target: null,
is_read: 0, is_read: false,
created_at: '2025-01-01T00:00:00.000Z', created_at: '2025-01-01T00:00:00.000Z',
...overrides, ...overrides,
}; };
@@ -92,14 +93,7 @@ describe('InAppNotificationBell', () => {
it('FE-COMP-BELL-007: panel shows Mark all read button when panel is open', async () => { it('FE-COMP-BELL-007: panel shows Mark all read button when panel is open', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const notification = { const notification = buildNotification({ id: 1, title_key: 'test', text_key: 'test.text' });
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',
};
seedStore(useInAppNotificationStore, { notifications: [notification], unreadCount: 1, isLoading: false }); seedStore(useInAppNotificationStore, { notifications: [notification], unreadCount: 1, isLoading: false });
render(<InAppNotificationBell />); render(<InAppNotificationBell />);
const bell = screen.getAllByRole('button')[0]; 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 () => { it('FE-COMP-BELL-013: Mark all read button NOT shown when unreadCount is 0', async () => {
const user = userEvent.setup(); 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 />); render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]); await user.click(screen.getAllByRole('button')[0]);
await screen.findByText('Notifications'); 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. */ /** 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) const valid = places.filter((p) => p.lat && p.lng)
if (valid.length <= 2) return places if (valid.length <= 2) return places
const visited = new Set<number>() const visited = new Set<number>()
const result: Waypoint[] = [] const result: T[] = []
let current = valid[0] let current = valid[0]
visited.add(0) visited.add(0)
result.push(current) result.push(current)
@@ -6,9 +6,10 @@ import { useSettingsStore } from '../../store/settingsStore';
import { useInAppNotificationStore } from '../../store/inAppNotificationStore'; import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildSettings } from '../../../tests/helpers/factories'; import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import type { InAppNotification } from '../../store/inAppNotificationStore';
import InAppNotificationItem from './InAppNotificationItem'; import InAppNotificationItem from './InAppNotificationItem';
const buildNotification = (overrides = {}) => ({ const buildNotification = (overrides: Partial<InAppNotification> = {}): InAppNotification => ({
id: 1, id: 1,
type: 'simple', type: 'simple',
scope: 'trip', scope: 'trip',
@@ -18,15 +19,15 @@ const buildNotification = (overrides = {}) => ({
sender_avatar: null, sender_avatar: null,
recipient_id: 1, recipient_id: 1,
title_key: 'notifications.title', title_key: 'notifications.title',
title_params: '{}', title_params: {},
text_key: 'notifications.empty', text_key: 'notifications.empty',
text_params: '{}', text_params: {},
positive_text_key: null, positive_text_key: null,
negative_text_key: null, negative_text_key: null,
response: null, response: null,
navigate_text_key: null, navigate_text_key: null,
navigate_target: null, navigate_target: null,
is_read: 0, is_read: false,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
...overrides, ...overrides,
}); });
@@ -62,12 +63,12 @@ describe('InAppNotificationItem', () => {
}); });
it('FE-COMP-NOTIF-005: shows Mark as read button for unread notification', () => { 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(); expect(screen.getByTitle('Mark as read')).toBeInTheDocument();
}); });
it('FE-COMP-NOTIF-006: does not show Mark as read button for read notification', () => { 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(); expect(screen.queryByTitle('Mark as read')).not.toBeInTheDocument();
}); });
@@ -80,7 +81,7 @@ describe('InAppNotificationItem', () => {
const user = userEvent.setup(); const user = userEvent.setup();
const markRead = vi.fn().mockResolvedValue(undefined); const markRead = vi.fn().mockResolvedValue(undefined);
seedStore(useInAppNotificationStore, { markRead }); 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')); await user.click(screen.getByTitle('Mark as read'));
expect(markRead).toHaveBeenCalledWith(42); expect(markRead).toHaveBeenCalledWith(42);
}); });
@@ -190,7 +191,7 @@ describe('InAppNotificationItem', () => {
type: 'navigate', type: 'navigate',
navigate_text_key: 'notifications.title', navigate_text_key: 'notifications.title',
navigate_target: '/trips/1', navigate_target: '/trips/1',
is_read: 0, is_read: false,
})} })}
onClose={onClose} onClose={onClose}
/> />
+4 -3
View File
@@ -3,7 +3,7 @@ import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons' 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 { 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 { 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 { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { splitReservationDateTime } from '../../utils/formatters' import { splitReservationDateTime } from '../../utils/formatters'
@@ -117,7 +117,8 @@ interface downloadTripPDFProps {
places: Place[] places: Place[]
assignments: AssignmentsMap assignments: AssignmentsMap
categories: Category[] categories: Category[]
dayNotes: DayNotesMap // Flattened across days: each note carries its own day_id (see downloadTripPDF callers).
dayNotes: DayNote[]
reservations?: any[] reservations?: any[]
t: (key: string, params?: Record<string, string | number>) => string t: (key: string, params?: Record<string, string | number>) => string
locale: 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')) .filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
const merged = [] 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 })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
dayReservations.forEach(r => { 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) 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, CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload,
} from 'lucide-react' } from 'lucide-react'
import type { PackingItem } from '../../types' import type { PackingItem, PackingBag } from '../../types'
const VORSCHLAEGE = [ const VORSCHLAEGE = [
{ name: 'Passport', category: 'Documents' }, { name: 'Passport', category: 'Documents' },
@@ -67,8 +67,6 @@ function katColor(kat, allCategories) {
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length] 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). */ /** 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 => export const itemWeight = (i: { weight_grams?: number | null; quantity?: number | null }): number =>
(i.weight_grams || 0) * (i.quantity || 1) (i.weight_grams || 0) * (i.quantity || 1)
@@ -818,7 +816,7 @@ function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSigna
if (filter === 'erledigt') return i.checked if (filter === 'erledigt') return i.checked
return true return true
}) })
const groups = {} const groups: Record<string, PackingItem[]> = {}
for (const item of filtered) { for (const item of filtered) {
const kat = item.category || t('packing.defaultCategory') const kat = item.category || t('packing.defaultCategory')
if (!groups[kat]) groups[kat] = [] 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 { vi, describe, it, expect, beforeEach, beforeAll } from 'vitest'
import { render } from '../../../tests/helpers/render' import { render } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store' import { resetAllStores } from '../../../tests/helpers/store'
import type { Day, Place } from '../../types'
import { PhotoUpload } from './PhotoUpload' import { PhotoUpload } from './PhotoUpload'
beforeAll(() => { beforeAll(() => {
@@ -12,8 +13,8 @@ beforeAll(() => {
const defaultProps = { const defaultProps = {
tripId: 1, tripId: 1,
days: [{ id: 1, day_number: 1, date: null }], days: [{ id: 1, trip_id: 1, day_number: 1, date: null }] as Day[],
places: [{ id: 1, name: 'Eiffel Tower' }], places: [{ id: 1, trip_id: 1, name: 'Eiffel Tower' }] as Place[],
onUpload: vi.fn().mockResolvedValue(undefined), onUpload: vi.fn().mockResolvedValue(undefined),
onClose: vi.fn(), onClose: vi.fn(),
} }
@@ -1393,7 +1393,7 @@ describe('DayPlanSidebar', () => {
const assignment = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) const assignment = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
const flight = buildReservation({ const flight = buildReservation({
id: 77, trip_id: 1, type: 'flight', status: 'confirmed', 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({ render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], days: [day], places: [place],
@@ -1553,7 +1553,7 @@ describe('DayPlanSidebar', () => {
const a2 = buildAssignment({ id: 22, day_id: 10, order_index: 1, place: placeB }) const a2 = buildAssignment({ id: 22, day_id: 10, order_index: 1, place: placeB })
const flight = buildReservation({ const flight = buildReservation({
id: 77, trip_id: 1, type: 'flight', status: 'confirmed', 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({ render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [placeA, placeB], days: [day], places: [placeA, placeB],
@@ -31,7 +31,7 @@ import {
import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters' import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes' import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip' 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 = [ const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText }, { id: 'FileText', Icon: FileText },
@@ -169,7 +169,7 @@ interface DayPlanSidebarProps {
onSelectDay: (dayId: number | null) => void onSelectDay: (dayId: number | null) => void
onPlaceClick: (placeId: number) => void onPlaceClick: (placeId: number) => void
onDayDetail: (day: Day) => void onDayDetail: (day: Day) => void
accommodations?: Assignment[] accommodations?: Accommodation[]
onReorder: (dayId: number, orderedIds: number[]) => void onReorder: (dayId: number, orderedIds: number[]) => void
onUpdateDayTitle: (dayId: number, title: string) => void onUpdateDayTitle: (dayId: number, title: string) => void
onRouteCalculated: (dayId: number, route: RouteResult | null) => void onRouteCalculated: (dayId: number, route: RouteResult | null) => void
@@ -277,9 +277,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [expandedDays, setExpandedDays] = useState(() => { const [expandedDays, setExpandedDays] = useState(() => {
try { try {
const saved = sessionStorage.getItem(`day-expanded-${tripId}`) 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 {} } catch {}
return new Set(days.map(d => d.id)) return new Set<number>(days.map(d => d.id))
}) })
useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays]) useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays])
const [editingDayId, setEditingDayId] = useState(null) const [editingDayId, setEditingDayId] = useState(null)
@@ -921,7 +921,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const totalCost = useMemo(() => days.reduce((s, d) => { const totalCost = useMemo(() => days.reduce((s, d) => {
const da = assignments[String(d.id)] || [] 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]) }, 0), [days, assignments])
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort // 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 */} {/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
{(() => { {(() => {
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat // anyGeoPlace is an assignment (has .place) or a bare place — read coords from either.
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng 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) const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
return ( return (
<div style={{ <div style={{
@@ -23,6 +23,10 @@ interface PlaceFormData {
notes: string notes: string
transport_mode: string transport_mode: string
website: 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 { 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', () => { 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( render(
<PlaceInspector <PlaceInspector
{...defaultProps} {...defaultProps}
@@ -250,7 +250,7 @@ describe('PlaceInspector', () => {
it('FE-PLANNER-INSPECTOR-018: clicking remove calls onRemoveAssignment with dayId and assignmentId', async () => { it('FE-PLANNER-INSPECTOR-018: clicking remove calls onRemoveAssignment with dayId and assignmentId', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onRemoveAssignment = vi.fn(); 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( render(
<PlaceInspector <PlaceInspector
{...defaultProps} {...defaultProps}
@@ -406,7 +406,7 @@ describe('PlaceInspector', () => {
it('FE-PLANNER-INSPECTOR-030: linked reservation shown when selectedAssignmentId has a reservation', () => { 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 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( render(
<PlaceInspector <PlaceInspector
{...defaultProps} {...defaultProps}
@@ -423,7 +423,7 @@ describe('PlaceInspector', () => {
it('FE-PLANNER-INSPECTOR-031: participants section shown when tripMembers > 1 and selectedAssignmentId is set', () => { it('FE-PLANNER-INSPECTOR-031: participants section shown when tripMembers > 1 and selectedAssignmentId is set', () => {
const members = [buildUser({ id: 1 }), buildUser({ id: 2 })]; 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( render(
<PlaceInspector <PlaceInspector
{...defaultProps} {...defaultProps}
@@ -534,7 +534,7 @@ describe('PlaceInspector', () => {
const member2 = buildUser({ id: 11, username: 'bob' }); const member2 = buildUser({ id: 11, username: 'bob' });
const members = [member1, member2]; const members = [member1, member2];
const assignmentInDay = [{ 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 }], participants: [{ user_id: 10 }],
}]; }];
render( render(
@@ -637,7 +637,7 @@ describe('PlaceInspector', () => {
tripMembers={[member]} tripMembers={[member]}
selectedDayId={1} selectedDayId={1}
selectedAssignmentId={99} 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 // "solo" username might be visible from other parts but participants box should not render
@@ -104,6 +104,7 @@ function formatFileSize(bytes) {
interface TripMember { interface TripMember {
id: number id: number
username: string username: string
avatar?: string | null
avatar_url?: 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 { useParams } from 'react-router-dom'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import { useTripStore } from '../../store/tripStore' 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, width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)', 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 ( return (
<Modal <Modal
@@ -13,7 +13,9 @@ import type { Trip } from '../../types'
interface TripFormModalProps { interface TripFormModalProps {
isOpen: boolean isOpen: boolean
onClose: () => void 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 trip: Trip | null
onCoverUpdate: (tripId: number, coverUrl: string) => void onCoverUpdate: (tripId: number, coverUrl: string) => void
} }
@@ -106,22 +108,23 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
reminder_days: formData.reminder_days, reminder_days: formData.reminder_days,
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}), ...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
}) })
const createdTrip = result ? result.trip : undefined
// Add selected members for newly created trips // 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) { for (const userId of selectedMembers) {
const user = allUsers.find(u => u.id === userId) const user = allUsers.find(u => u.id === userId)
if (user) { 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 // Upload pending cover for newly created trips
if (pendingCoverFile && result?.trip?.id) { if (pendingCoverFile && createdTrip?.id) {
try { try {
const fd = new FormData() const fd = new FormData()
fd.append('cover', pendingCoverFile) fd.append('cover', pendingCoverFile)
const data = await tripsApi.uploadCover(result.trip.id, fd) const data = await tripsApi.uploadCover(createdTrip.id, fd)
onCoverUpdate?.(result.trip.id, data.cover_image) onCoverUpdate?.(createdTrip.id, data.cover_image)
} catch { } catch {
// Cover upload failed but trip was created // 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 () => { it('FE-COMP-MEMBERS-016: share link section not rendered for non-owner', async () => {
const nonOwner = buildUser({ id: 99, username: 'stranger' }); const nonOwner = buildUser({ id: 99, username: 'stranger' });
seedStore(useAuthStore, { user: nonOwner, isAuthenticated: true }); 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' } }); seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
render(<TripMembersModal {...defaultProps} />); render(<TripMembersModal {...defaultProps} />);
@@ -190,7 +190,7 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-017: share link section visible for owner', async () => { it('FE-COMP-MEMBERS-017: share link section visible for owner', async () => {
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); 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} />); render(<TripMembersModal {...defaultProps} />);
await screen.findByText('Public Link'); 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 () => { it('FE-COMP-MEMBERS-018: create share link shows URL after clicking create', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); 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 // GET returns null token initially; POST returns a new token
server.use( server.use(
@@ -229,7 +229,7 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-019: copy share link calls clipboard.writeText', async () => { it('FE-COMP-MEMBERS-019: copy share link calls clipboard.writeText', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); 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); const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { 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 () => { it('FE-COMP-MEMBERS-020: delete share link removes URL and shows create button', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); 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; let deleteHandlerCalled = false;
server.use( server.use(
@@ -292,7 +292,7 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-021: clicking permission toggle calls POST with updated perms', async () => { it('FE-COMP-MEMBERS-021: clicking permission toggle calls POST with updated perms', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); 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; let postedPerms: Record<string, unknown> | null = null;
server.use( server.use(
@@ -376,7 +376,7 @@ describe('TripMembersModal', () => {
}); });
seedStore(useAuthStore, { user: memberUser, isAuthenticated: true }); 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; let deleteCalledForUserId: string | null = null;
server.use( server.use(
@@ -36,7 +36,7 @@ export default function VacayCalendar() {
}, [selectedYear]) }, [selectedYear])
const companyHolidaySet = useMemo(() => { const companyHolidaySet = useMemo(() => {
const s = new Set() const s = new Set<string>()
companyHolidays.forEach(h => s.add(h.date)) companyHolidays.forEach(h => s.add(h.date))
return s return s
}, [companyHolidays]) }, [companyHolidays])
@@ -49,7 +49,7 @@ describe('VacayMonthCard', () => {
it('FE-COMP-VACAYMONTHCARD-004: Holiday cell has tooltip with localName', () => { it('FE-COMP-VACAYMONTHCARD-004: Holiday cell has tooltip with localName', () => {
const props = { const props = {
...baseProps, ...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} />) render(<VacayMonthCard {...props} />)
// Jan 1 is a Wednesday — there may be multiple "1" text nodes, find the one with a title // 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', () => { it('FE-COMP-VACAYMONTHCARD-005: Holiday cell with label shows combined tooltip', () => {
const props = { const props = {
...baseProps, ...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} />) render(<VacayMonthCard {...props} />)
const cell = screen.getByTitle('DE: New Year') const cell = screen.getByTitle('DE: New Year')
@@ -95,7 +95,7 @@ describe('VacayMonthCard', () => {
it('FE-COMP-VACAYMONTHCARD-008: Single vacation entry renders colored overlay', () => { it('FE-COMP-VACAYMONTHCARD-008: Single vacation entry renders colored overlay', () => {
const props = { const props = {
...baseProps, ...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} />) render(<VacayMonthCard {...props} />)
const daySpan = screen.getByText('15') 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', () => { it('FE-COMP-VACAYMONTHCARD-009: Day number font-weight is bold when entries exist', () => {
const props = { const props = {
...baseProps, ...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} />) render(<VacayMonthCard {...props} />)
const daySpan = screen.getByText('20') const daySpan = screen.getByText('20')
@@ -131,7 +131,7 @@ describe('VacayMonthCard', () => {
const props = { const props = {
...baseProps, ...baseProps,
entryMap: { 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} />) render(<VacayMonthCard {...props} />)
@@ -149,10 +149,10 @@ describe('VacayMonthCard', () => {
...baseProps, ...baseProps,
entryMap: { entryMap: {
'2025-01-15': [ '2025-01-15': [
{ person_color: '#6366f1' }, { date: '2025-01-15', user_id: 1, person_color: '#6366f1' },
{ person_color: '#f43f5e' }, { date: '2025-01-15', user_id: 1, person_color: '#f43f5e' },
{ person_color: '#22c55e' }, { date: '2025-01-15', user_id: 1, person_color: '#22c55e' },
{ person_color: '#f59e0b' }, { 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 { useVacayStore } from '../../store/vacayStore'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n' 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() { export default function VacayStats() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -50,17 +44,19 @@ export default function VacayStats() {
} }
interface StatCardProps { interface StatCardProps {
stat: VacayStatExtended stat: VacayStat
isMe: boolean isMe: boolean
canEdit: boolean canEdit: boolean
selectedYear: number selectedYear: number
onSave: (userId: number, year: number, days: number) => Promise<void> 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) { function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardProps) {
const [editing, setEditing] = useState(false) 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 const pct = s.total_available > 0 ? Math.min(100, (s.used / s.total_available) * 100) : 0
// Sync local state when stats reload from server // Sync local state when stats reload from server
@@ -70,7 +66,7 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
const handleSave = () => { const handleSave = () => {
setEditing(false) setEditing(false)
const days = parseInt(localDays) const days = parseInt(String(localDays))
if (!isNaN(days) && days >= 0 && days <= 365 && days !== s.vacation_days) { if (!isNaN(days) && days >= 0 && days <= 365 && days !== s.vacation_days) {
onSave(selectedYear, days, s.user_id) onSave(selectedYear, days, s.user_id)
} }
@@ -3,7 +3,9 @@ import ReactDOM from 'react-dom'
import { ChevronDown, Check } from 'lucide-react' import { ChevronDown, Check } from 'lucide-react'
interface SelectOption { 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 label: string
icon?: React.ReactNode icon?: React.ReactNode
isHeader?: boolean isHeader?: boolean
@@ -13,8 +15,8 @@ interface SelectOption {
} }
interface CustomSelectProps { interface CustomSelectProps {
value: string value: string | number
onChange: (value: string) => void onChange: (value: string | number) => void
options?: SelectOption[] options?: SelectOption[]
placeholder?: string placeholder?: string
searchable?: boolean searchable?: boolean
+16 -16
View File
@@ -360,7 +360,7 @@ describe('AdminPage', () => {
fireEvent.click(screen.getByRole('button', { name: /settings/i })); fireEvent.click(screen.getByRole('button', { name: /settings/i }));
const heading = await screen.findByRole('heading', { name: /authentication methods/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'); const toggles = within(card!).getAllByRole('button');
fireEvent.click(toggles[0]); // First toggle = password_login fireEvent.click(toggles[0]); // First toggle = password_login
@@ -474,7 +474,7 @@ describe('AdminPage', () => {
fireEvent.click(screen.getByRole('button', { name: /settings/i })); fireEvent.click(screen.getByRole('button', { name: /settings/i }));
const mfaHeading = await screen.findByRole('heading', { name: /require two-factor/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'); const mfaToggle = within(mfaCard!).getByRole('button');
fireEvent.click(mfaToggle); fireEvent.click(mfaToggle);
@@ -739,7 +739,7 @@ describe('AdminPage', () => {
// Find and click the Save button in the file types section // Find and click the Save button in the file types section
const fileTypesHeading = screen.getByRole('heading', { name: /allowed file types/i }); 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 }); const saveBtn = within(fileTypesCard!).getByRole('button', { name: /save/i });
fireEvent.click(saveBtn); fireEvent.click(saveBtn);
@@ -765,7 +765,7 @@ describe('AdminPage', () => {
// Wait for OIDC section to appear // Wait for OIDC section to appear
const oidcHeading = await screen.findByRole('heading', { name: /single sign-on/i }); 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') // Type in the display name field (placeholder is 'z.B. Google, Authentik, Keycloak')
const displayNameInput = within(oidcCard!).getByPlaceholderText('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 // The Email (SMTP) panel header has the enable toggle
const emailHeading = await screen.findByRole('heading', { name: /email \(smtp\)/i }); 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]; const emailToggle = within(emailPanel!).getAllByRole('button')[0];
fireEvent.click(emailToggle); fireEvent.click(emailToggle);
@@ -842,7 +842,7 @@ describe('AdminPage', () => {
// Click Save in the email panel // Click Save in the email panel
const emailHeading = screen.getByRole('heading', { name: /email \(smtp\)/i }); 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 }); const saveBtn = within(emailPanel!).getByRole('button', { name: /^save$/i });
fireEvent.click(saveBtn); fireEvent.click(saveBtn);
@@ -964,7 +964,7 @@ describe('AdminPage', () => {
// Wait for the API Keys section to appear // Wait for the API Keys section to appear
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i }); 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) // Type in the maps key field (type="password" by default)
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...'); const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
@@ -999,7 +999,7 @@ describe('AdminPage', () => {
// Wait for the API Keys section // Wait for the API Keys section
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i }); 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 // Type a key value to enable the Test button
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...'); const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
@@ -1126,7 +1126,7 @@ describe('AdminPage', () => {
// Click the TLS toggle (skip TLS certificate check) // Click the TLS toggle (skip TLS certificate check)
const tlsToggleText = screen.getByText('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 // The toggle button is a sibling container
const allToggles = screen.getAllByRole('button'); const allToggles = screen.getAllByRole('button');
// Find toggle near the TLS text // 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) // 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 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 }); const testBtn = within(emailPanel!).getByRole('button', { name: /send test email/i });
fireEvent.click(testBtn); fireEvent.click(testBtn);
@@ -1207,7 +1207,7 @@ describe('AdminPage', () => {
// Find the webhook panel heading ('Webhook') — exact match to avoid 'Admin Webhook' // Find the webhook panel heading ('Webhook') — exact match to avoid 'Admin Webhook'
const webhookHeading = screen.getByRole('heading', { name: /^webhook$/i }); 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 // Find the toggle button in webhook card
const webhookToggle = within(webhookCard!).getByRole('button'); const webhookToggle = within(webhookCard!).getByRole('button');
fireEvent.click(webhookToggle); fireEvent.click(webhookToggle);
@@ -1245,7 +1245,7 @@ describe('AdminPage', () => {
// Find the Save button in the admin webhook panel // Find the Save button in the admin webhook panel
const adminWebhookHeading = screen.getByRole('heading', { name: /admin webhook/i }); 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 }); const saveBtn = within(adminWebhookCard!).getByRole('button', { name: /save/i });
fireEvent.click(saveBtn); fireEvent.click(saveBtn);
@@ -1284,7 +1284,7 @@ describe('AdminPage', () => {
// The channel column header is t('settings.notificationPreferences.email') = 'Email' (CSS uppercases it) // The channel column header is t('settings.notificationPreferences.email') = 'Email' (CSS uppercases it)
// Find the AdminNotificationsPanel by its h2 heading role='heading' // Find the AdminNotificationsPanel by its h2 heading role='heading'
const matrixHeading = await screen.findByRole('heading', { name: /^notifications$/i }); 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) // The matrix toggle button is inside the card (not a checkbox — it's a button toggle)
const matrixToggle = matrixCard?.querySelector('button'); const matrixToggle = matrixCard?.querySelector('button');
@@ -1308,7 +1308,7 @@ describe('AdminPage', () => {
// Wait for the OIDC section — heading is 'Single Sign-On (OIDC)' // Wait for the OIDC section — heading is 'Single Sign-On (OIDC)'
const oidcHeading = await screen.findByRole('heading', { name: /single sign-on/i }); 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) // Issuer field (placeholder: https://accounts.google.com)
const issuerInput = within(oidcCard!).getByPlaceholderText('https://accounts.google.com'); const issuerInput = within(oidcCard!).getByPlaceholderText('https://accounts.google.com');
@@ -1320,12 +1320,12 @@ describe('AdminPage', () => {
// Client ID field // Client ID field
const clientIdLabel = within(oidcCard!).getByText('Client ID'); 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' } }); fireEvent.change(clientIdInput, { target: { value: 'my-client-id' } });
// Client Secret field // Client Secret field
const clientSecretLabel = within(oidcCard!).getByText('Client Secret'); 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' } }); fireEvent.change(clientSecretInput, { target: { value: 'my-client-secret' } });
// Verify the inputs updated // 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', () => { 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 () => { 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( server.use(
http.put('/api/trips/:id', () => HttpResponse.json({ trip: archivedTrip })), 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', () => { describe('FE-PAGE-DASH-014: Archive filter reveals archived trips', () => {
it('shows archived trips when the archive filter is selected', async () => { 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( server.use(
http.get('/api/trips', ({ request }) => { http.get('/api/trips', ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
@@ -414,8 +414,8 @@ describe('DashboardPage', () => {
describe('FE-PAGE-DASH-020: Archived section - restore trip', () => { describe('FE-PAGE-DASH-020: Archived section - restore trip', () => {
it('clicking restore in archived section moves trip back to active list', async () => { 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 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 archivedTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: 1 });
const restoredTrip = { ...archivedTrip, is_archived: false }; const restoredTrip = { ...archivedTrip, is_archived: 0 };
server.use( server.use(
http.get('/api/trips', ({ request }) => { 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', () => { 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 () => { 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 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( server.use(
http.get('/api/trips', ({ request }) => { http.get('/api/trips', ({ request }) => {
@@ -660,8 +660,8 @@ describe('DashboardPage', () => {
describe('FE-PAGE-DASH-028: Unarchive action restores trip to active list', () => { 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 () => { 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 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 archivedTrip = buildTrip({ title: 'Restored Trip', start_date: '2024-06-01', end_date: '2024-06-07', is_archived: 1 });
const restoredTrip = { ...archivedTrip, is_archived: false }; const restoredTrip = { ...archivedTrip, is_archived: 0 };
server.use( server.use(
http.get('/api/trips', ({ request }) => { http.get('/api/trips', ({ request }) => {
+2 -2
View File
@@ -25,10 +25,10 @@ function buildPhoto(overrides: Partial<Photo> = {}): Photo {
return { return {
id: 1, id: 1,
trip_id: 1, trip_id: 1,
filename: 'photo1.jpg', url: '/uploads/photos/photo1.jpg',
original_name: 'photo1.jpg', original_name: 'photo1.jpg',
mime_type: 'image/jpeg', mime_type: 'image/jpeg',
size: 12345, file_size: 12345,
caption: null, caption: null,
place_id: null, place_id: null,
day_id: null, day_id: null,
@@ -72,7 +72,7 @@ export function useTripPlanner() {
useEffect(() => { useEffect(() => {
addonsApi.enabled().then(data => { addonsApi.enabled().then(data => {
const map = {} const map: Record<string, boolean> = {}
data.addons.forEach(a => { map[a.id] = true }) data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab }) setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
if (data.collabFeatures) setCollabFeatures(data.collabFeatures) if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
@@ -413,7 +413,6 @@ export function useTripPlanner() {
lng: capturedPlace.lng, lng: capturedPlace.lng,
address: capturedPlace.address, address: capturedPlace.address,
category_id: capturedPlace.category_id, category_id: capturedPlace.category_id,
icon: capturedPlace.icon,
price: capturedPlace.price, price: capturedPlace.price,
}) })
for (const { dayId, orderIndex } of capturedAssignments) { for (const { dayId, orderIndex } of capturedAssignments) {
@@ -444,7 +443,7 @@ export function useTripPlanner() {
const newPlace = await tripActions.addPlace(tripId, { const newPlace = await tripActions.addPlace(tripId, {
name: place.name, description: place.description, name: place.name, description: place.description,
lat: place.lat, lng: place.lng, address: place.address, 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)) { for (const a of capturedAssignments.filter(x => x.placeId === place.id)) {
await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex) await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex)
+1 -1
View File
@@ -125,7 +125,7 @@ describe('budgetSlice', () => {
const item = buildBudgetItem({ const item = buildBudgetItem({
id: 8, id: 8,
trip_id: 1, trip_id: 1,
members: [{ user_id: 3, paid: false }], members: [{ user_id: 3, paid: 0, username: 'carol' }],
}); });
seedStore(useTripStore, { budgetItems: [item] }); seedStore(useTripStore, { budgetItems: [item] });
+9 -5
View File
@@ -2,7 +2,8 @@ import { budgetApi } from '../../api/client'
import { budgetRepo } from '../../repo/budgetRepo' import { budgetRepo } from '../../repo/budgetRepo'
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore' 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' import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState'] type SetState = StoreApi<TripStoreState>['setState']
@@ -13,7 +14,7 @@ export interface BudgetSlice {
addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem> addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem>
updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem> updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem>
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void> 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> toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void> reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => 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) => { addBudgetItem: async (tripId, data) => {
try { try {
const result = await budgetApi.create(tripId, data) const result = await budgetApi.create(tripId, data as BudgetCreateItemRequest)
set(state => ({ budgetItems: [...state.budgetItems, result.item] })) set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item return result.item
} catch (err: unknown) { } catch (err: unknown) {
@@ -41,7 +42,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
updateBudgetItem: async (tripId, id, data) => { updateBudgetItem: async (tripId, id, data) => {
try { try {
const result = await budgetApi.update(tripId, id, data) const result = await budgetApi.update(tripId, id, data as BudgetUpdateItemRequest)
set(state => ({ set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item) 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 => ({ set(state => ({
budgetItems: state.budgetItems.map(item => budgetItems: state.budgetItems.map(item =>
item.id === itemId 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 : item
) )
})); }));
@@ -1,6 +1,6 @@
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore' 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' import { offlineDb } from '../../db/offlineDb'
type SetState = StoreApi<TripStoreState>['setState'] type SetState = StoreApi<TripStoreState>['setState']
@@ -250,7 +250,7 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
case 'assignment:reordered': { case 'assignment:reordered': {
const dayKey = String(payload.dayId) const dayKey = String(payload.dayId)
const currentItems = state.assignments[dayKey] || [] 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 reordered = orderedIds.map((id, idx) => {
const item = currentItems.find(a => a.id === id) const item = currentItems.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null return item ? { ...item, order_index: idx } : null
@@ -356,7 +356,7 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
case 'budget:members-updated': case 'budget:members-updated':
return { return {
budgetItems: state.budgetItems.map(i => 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': case 'budget:member-paid-updated':
+2 -2
View File
@@ -35,7 +35,7 @@ interface VacayYearsResponse {
interface VacayEntriesResponse { interface VacayEntriesResponse {
entries: VacayEntry[] entries: VacayEntry[]
companyHolidays: string[] companyHolidays: { date: string; note?: string }[]
} }
interface VacayStatsResponse { interface VacayStatsResponse {
@@ -109,7 +109,7 @@ interface VacayState {
isFused: boolean isFused: boolean
years: number[] years: number[]
entries: VacayEntry[] entries: VacayEntry[]
companyHolidays: string[] companyHolidays: { date: string; note?: string }[]
stats: VacayStat[] stats: VacayStat[]
selectedYear: number selectedYear: number
selectedUserId: number | null 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 { export interface User {
id: number id: number
@@ -14,85 +63,6 @@ export interface User {
must_change_password?: boolean 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 { export interface TodoItem {
id: number id: number
trip_id: number trip_id: number
@@ -106,82 +76,6 @@ export interface TodoItem {
priority: number 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 { export interface TripFile {
id: number id: number
trip_id: number trip_id: number
@@ -200,8 +94,10 @@ export interface TripFile {
deleted_at?: string | null deleted_at?: string | null
created_at: string created_at: string
reservation_title?: string reservation_title?: string
linked_reservation_ids?: number[] linked_reservation_ids?: (number | null)[]
url?: string linked_place_ids?: (number | null)[]
/** Served download path — always present on list/create/update responses (formatFile). */
url: string
} }
export interface Settings { export interface Settings {
@@ -271,41 +167,20 @@ export interface UserWithOidc extends User {
oidc_issuer?: string | null oidc_issuer?: string | null
} }
// Accommodation type // Photo type — trip photo as consumed by the PhotosPage / PhotoGallery /
export interface Accommodation { // PhotoLightbox surface (photos table joined with a served `url`). file_size is
id: number // the photos.file_size column; url is the served upload path.
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
export interface Photo { export interface Photo {
id: number id: number
trip_id: number trip_id?: number
filename: string url: string
original_name: string original_name: string
mime_type: string mime_type?: string
size: number file_size?: number | null
caption: string | null caption: string | null
place_id: number | null place_id: number | null
day_id: number | null day_id: number | null
taken_at?: string | null
created_at: string created_at: string
} }
@@ -381,6 +256,8 @@ export interface VacayPlan {
block_weekends: boolean block_weekends: boolean
carry_over_enabled: boolean carry_over_enabled: boolean
company_holidays_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 week_start?: number
name?: string name?: string
year?: number year?: number
@@ -403,10 +280,18 @@ export interface VacayEntry {
person_name?: string person_name?: string
} }
// Vacay per-user stats row as returned by getStats
// (server/src/services/vacayService.ts -> getStats).
export interface VacayStat { export interface VacayStat {
user_id: number user_id: number
person_name: string
person_color: string
year: number
vacation_days: number vacation_days: number
carried_over: number
total_available: number
used: number used: number
remaining: number
} }
export interface HolidayInfo { 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 { export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
const da = assignments[String(dayId)] || [] 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 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(); const id = next();
return { return {
id, id,
name: `Trip ${id}`, user_id: 1,
title: `Trip ${id}`,
description: null, description: null,
start_date: '2025-06-01', start_date: '2025-06-01',
end_date: '2025-06-05', end_date: '2025-06-05',
cover_url: null, currency: 'EUR',
is_archived: false, cover_image: null,
is_archived: 0,
reminder_days: 7, reminder_days: 7,
owner_id: 1,
created_at: '2025-01-01T00:00:00.000Z', created_at: '2025-01-01T00:00:00.000Z',
updated_at: '2025-01-01T00:00:00.000Z', updated_at: '2025-01-01T00:00:00.000Z',
...overrides, ...overrides,
@@ -105,14 +106,19 @@ export function buildPlace(overrides: Partial<Place> = {}): Place {
lng: 2.3522, lng: 2.3522,
address: null, address: null,
category_id: null, category_id: null,
icon: null,
price: null, price: null,
currency: null,
image_url: null, image_url: null,
google_place_id: null, google_place_id: null,
osm_id: null, osm_id: null,
route_geometry: null, route_geometry: null,
place_time: null, place_time: null,
end_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', created_at: '2025-01-01T00:00:00.000Z',
...overrides, ...overrides,
}; };
@@ -154,6 +160,7 @@ export function buildPackingItem(overrides: Partial<PackingItem> = {}): PackingI
name: `Packing item ${id}`, name: `Packing item ${id}`,
category: null, category: null,
checked: 0, checked: 0,
sort_order: 0,
quantity: 1, quantity: 1,
...overrides, ...overrides,
}; };
@@ -181,14 +188,16 @@ export function buildBudgetItem(overrides: Partial<BudgetItem> = {}): BudgetItem
return { return {
id, id,
trip_id: 1, trip_id: 1,
category: 'Other',
name: `Budget item ${id}`, name: `Budget item ${id}`,
amount: 100, total_price: 100,
currency: 'EUR',
category: null,
paid_by: null,
persons: 1, persons: 1,
days: null,
note: null,
sort_order: 0,
members: [], members: [],
expense_date: null, expense_date: null,
created_at: '2025-01-01T00:00:00.000Z',
...overrides, ...overrides,
}; };
} }
@@ -198,14 +207,14 @@ export function buildReservation(overrides: Partial<Reservation> = {}): Reservat
return { return {
id, id,
trip_id: 1, trip_id: 1,
name: `Reservation ${id}`, title: `Reservation ${id}`,
type: 'restaurant', type: 'restaurant',
status: 'confirmed', status: 'confirmed',
date: null, reservation_time: null,
time: null, reservation_end_time: null,
location: null,
confirmation_number: null, confirmation_number: null,
notes: null, notes: null,
url: null,
created_at: '2025-01-01T00:00:00.000Z', created_at: '2025-01-01T00:00:00.000Z',
...overrides, ...overrides,
}; };
@@ -219,6 +228,7 @@ export function buildTripFile(overrides: Partial<TripFile> = {}): TripFile {
filename: 'test.pdf', filename: 'test.pdf',
original_name: 'test.pdf', original_name: 'test.pdf',
mime_type: 'application/pdf', mime_type: 'application/pdf',
url: `/api/trips/1/files/${id}/download`,
created_at: '2025-01-01T00:00:00.000Z', created_at: '2025-01-01T00:00:00.000Z',
...overrides, ...overrides,
}; };
@@ -240,6 +250,7 @@ export function buildCategory(overrides: Partial<Category> = {}): Category {
return { return {
id, id,
name: `Category ${id}`, name: `Category ${id}`,
color: '#6366f1',
icon: 'restaurant', icon: 'restaurant',
user_id: 1, user_id: 1,
...overrides, ...overrides,
+10 -2
View File
@@ -25,9 +25,17 @@ export function resetAllStores(): void {
usePermissionsStore.setState(initialPermsState, true); 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>( export function seedStore<T extends object>(
store: { setState: (partial: Partial<T>, replace?: boolean) => void }, store: { setState: (partial: Partial<T>, replace?: boolean) => void },
state: Partial<T>, state: DeepPartial<T>,
): void { ): 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 ────────────────────────────────────────────── // ── tripsApi additional methods ──────────────────────────────────────────────
it('tripsApi.create posts new trip', async () => { it('tripsApi.create posts new trip', async () => {
server.use(http.post('/api/trips', () => HttpResponse.json({ id: 1, name: 'Test' }))); server.use(http.post('/api/trips', () => HttpResponse.json({ id: 1, title: 'Test' })));
await expect(tripsApi.create({ name: 'Test' })).resolves.toMatchObject({ id: 1 }); await expect(tripsApi.create({ title: 'Test' })).resolves.toMatchObject({ id: 1 });
}); });
it('tripsApi.update puts trip data', async () => { it('tripsApi.update puts trip data', async () => {
server.use(http.put('/api/trips/1', () => HttpResponse.json({ id: 1 }))); 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 () => { it('tripsApi.delete deletes a trip', async () => {
@@ -765,7 +765,7 @@ describe('API namespace smoke tests', () => {
it('reservationsApi.create creates a reservation', async () => { it('reservationsApi.create creates a reservation', async () => {
server.use(http.post('/api/trips/1/reservations', () => HttpResponse.json({ id: 1 }))); 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 () => { it('reservationsApi.delete deletes a reservation', async () => {
@@ -784,7 +784,7 @@ describe('API namespace smoke tests', () => {
it('accommodationsApi.create creates accommodation', async () => { it('accommodationsApi.create creates accommodation', async () => {
server.use(http.post('/api/trips/1/accommodations', () => HttpResponse.json({ id: 1 }))); 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 () => { 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 => ({ const makeTrip = (id = 1): Trip => ({
id, id,
name: `Trip ${id}`, user_id: 42,
title: `Trip ${id}`,
description: null, description: null,
start_date: '2026-07-01', start_date: '2026-07-01',
end_date: '2026-07-05', end_date: '2026-07-05',
cover_url: null, currency: 'EUR',
is_archived: false, cover_image: null,
is_archived: 0,
reminder_days: 3, reminder_days: 3,
owner_id: 42,
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
updated_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, lng: 2.3522,
address: null, address: null,
category_id: null, category_id: null,
icon: null,
price: null, price: null,
currency: null, currency: null,
image_url: null, image_url: null,
@@ -102,14 +102,14 @@ describe('offlineDb — trips', () => {
await upsertTrip(trip); await upsertTrip(trip);
const stored = await offlineDb.trips.get(10); const stored = await offlineDb.trips.get(10);
expect(stored).toBeDefined(); expect(stored).toBeDefined();
expect(stored!.name).toBe('Trip 10'); expect(stored!.title).toBe('Trip 10');
}); });
it('upsertTrip overwrites an existing trip (put semantics)', async () => { it('upsertTrip overwrites an existing trip (put semantics)', async () => {
await upsertTrip(makeTrip(1)); await upsertTrip(makeTrip(1));
await upsertTrip({ ...makeTrip(1), name: 'Updated' }); await upsertTrip({ ...makeTrip(1), title: 'Updated' });
const stored = await offlineDb.trips.get(1); 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', () => { describe('offlineDb — packing / todo / budget / reservations / files', () => {
it('upserts packing items', async () => { 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]); await upsertPackingItems([item]);
expect(await offlineDb.packingItems.count()).toBe(1); expect(await offlineDb.packingItems.count()).toBe(1);
}); });
@@ -149,8 +149,8 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => {
it('upserts budget items', async () => { it('upserts budget items', async () => {
const item: BudgetItem = { const item: BudgetItem = {
id: 1, trip_id: 1, name: 'Flight', amount: 500, currency: 'EUR', id: 1, trip_id: 1, name: 'Flight', total_price: 500,
category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null, category: 'Transport', persons: 1, members: [], expense_date: null, sort_order: 0,
}; };
await upsertBudgetItems([item]); await upsertBudgetItems([item]);
expect(await offlineDb.budgetItems.count()).toBe(1); expect(await offlineDb.budgetItems.count()).toBe(1);
@@ -158,8 +158,8 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => {
it('upserts reservations', async () => { it('upserts reservations', async () => {
const item: Reservation = { const item: Reservation = {
id: 1, trip_id: 1, name: 'Hotel', type: 'hotel', status: 'confirmed', id: 1, trip_id: 1, title: 'Hotel', type: 'hotel', status: 'confirmed',
date: null, time: null, confirmation_number: null, notes: null, url: null, created_at: '2026-01-01T00:00:00Z', reservation_time: null, confirmation_number: null, notes: null, created_at: '2026-01-01T00:00:00Z',
}; };
await upsertReservations([item]); await upsertReservations([item]);
expect(await offlineDb.reservations.count()).toBe(1); expect(await offlineDb.reservations.count()).toBe(1);
@@ -168,7 +168,7 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => {
it('upserts trip files', async () => { it('upserts trip files', async () => {
const file: TripFile = { const file: TripFile = {
id: 1, trip_id: 1, filename: 'ticket.pdf', original_name: 'Ticket.pdf', 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]); await upsertTripFiles([file]);
expect(await offlineDb.tripFiles.count()).toBe(1); expect(await offlineDb.tripFiles.count()).toBe(1);
@@ -238,7 +238,7 @@ describe('offlineDb — clearTripData', () => {
await upsertTrip(makeTrip(1)); await upsertTrip(makeTrip(1));
await upsertDays([makeDay(1, 1), makeDay(2, 1)]); await upsertDays([makeDay(1, 1), makeDay(2, 1)]);
await upsertPlaces([makePlace(10, 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]); await upsertPackingItems([item]);
// Also add data for a different trip — should NOT be removed // 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 { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores } from '../../helpers/store'; import { resetAllStores } from '../../helpers/store';
import { buildBudgetItem } from '../../helpers/factories'; import { buildBudgetItem } from '../../helpers/factories';
import type { BudgetMember } from '../../../src/types'; import type { BudgetItemMember } from '../../../src/types';
beforeEach(() => { beforeEach(() => {
resetAllStores(); resetAllStores();
}); });
describe('remoteEventHandler > budget', () => { describe('remoteEventHandler > budget', () => {
const member1: BudgetMember = { user_id: 5, paid: false }; const member1: BudgetItemMember = { user_id: 5, paid: 0, username: 'eve' };
const member2: BudgetMember = { user_id: 6, paid: true }; const member2: BudgetItemMember = { user_id: 6, paid: 1, username: 'frank' };
const seedData = () => { const seedData = () => {
useTripStore.setState({ useTripStore.setState({
@@ -40,12 +40,12 @@ describe('remoteEventHandler > budget', () => {
it('FE-WSEVT-BUDGET-003: budget:updated replaces item in array', () => { it('FE-WSEVT-BUDGET-003: budget:updated replaces item in array', () => {
seedData(); 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 }); useTripStore.getState().handleRemoteEvent({ type: 'budget:updated', item: updated });
const { budgetItems } = useTripStore.getState(); const { budgetItems } = useTripStore.getState();
const item = budgetItems.find(i => i.id === 1); const item = budgetItems.find(i => i.id === 1);
expect(item?.name).toBe('Updated Hotel'); 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', () => { 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', () => { it('FE-WSEVT-BUDGET-005: budget:members-updated replaces entire members array and persons count', () => {
seedData(); 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({ useTripStore.getState().handleRemoteEvent({
type: 'budget:members-updated', type: 'budget:members-updated',
itemId: 1, itemId: 1,
@@ -86,8 +86,8 @@ describe('remoteEventHandler > budget', () => {
const item = budgetItems.find(i => i.id === 1); const item = budgetItems.find(i => i.id === 1);
const m = item?.members?.find(m => m.user_id === 5); const m = item?.members?.find(m => m.user_id === 5);
expect(m?.paid).toBe(true); 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); 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', () => { describe('remoteEventHandler > reservations', () => {
const seedData = () => { const seedData = () => {
useTripStore.setState({ 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', () => { it('FE-WSEVT-RESERV-001: reservation:created prepends new reservation to array', () => {
seedData(); seedData();
const newRes = buildReservation({ id: 99, name: 'Flight' }); const newRes = buildReservation({ id: 99, title: 'Flight' });
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: newRes }); useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: newRes });
const { reservations } = useTripStore.getState(); const { reservations } = useTripStore.getState();
expect(reservations).toHaveLength(2); 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', () => { it('FE-WSEVT-RESERV-002: reservation:created is idempotent — no duplicate if same ID', () => {
seedData(); 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 }); useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: duplicate });
const { reservations } = useTripStore.getState(); const { reservations } = useTripStore.getState();
expect(reservations).toHaveLength(1); 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', () => { it('FE-WSEVT-RESERV-003: reservation:updated replaces reservation in array', () => {
seedData(); 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 }); useTripStore.getState().handleRemoteEvent({ type: 'reservation:updated', reservation: updated });
const { reservations } = useTripStore.getState(); 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', () => { 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', () => { it('FE-WSEVT-RESERV-005: reservation:created ordering — newest is first', () => {
seedData(); seedData();
const r2 = buildReservation({ id: 2, name: 'Second' }); const r2 = buildReservation({ id: 2, title: 'Second' });
const r3 = buildReservation({ id: 3, name: 'Third' }); const r3 = buildReservation({ id: 3, title: 'Third' });
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r2 }); useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r2 });
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r3 }); useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r3 });
const { reservations } = useTripStore.getState(); const { reservations } = useTripStore.getState();
@@ -9,21 +9,21 @@ beforeEach(() => {
describe('remoteEventHandler > trip', () => { describe('remoteEventHandler > trip', () => {
it('FE-WSEVT-TRIP-001: trip:updated replaces trip in state', () => { 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 }); 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 }); useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip });
const { trip } = useTripStore.getState(); 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', () => { it('FE-WSEVT-TRIP-002: trip:updated does not affect other state fields', () => {
const existingPlace = buildPlace({ id: 55, name: 'Eiffel Tower' }); const existingPlace = buildPlace({ id: 55, name: 'Eiffel Tower' });
useTripStore.setState({ useTripStore.setState({
trip: buildTrip({ id: 1, name: 'Original' }), trip: buildTrip({ id: 1, title: 'Original' }),
places: [existingPlace], places: [existingPlace],
}); });
const updatedTrip = buildTrip({ id: 1, name: 'Updated' }); const updatedTrip = buildTrip({ id: 1, title: 'Updated' });
useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip }); useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip });
const { places } = useTripStore.getState(); const { places } = useTripStore.getState();
expect(places).toHaveLength(1); expect(places).toHaveLength(1);
+7 -7
View File
@@ -43,7 +43,7 @@ describe('budgetSlice', () => {
const existing = buildBudgetItem({ trip_id: 1 }); const existing = buildBudgetItem({ trip_id: 1 });
seedStore(useTripStore, { budgetItems: [existing] }); 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(result.name).toBe('Hotel');
expect(useTripStore.getState().budgetItems).toHaveLength(2); expect(useTripStore.getState().budgetItems).toHaveLength(2);
@@ -64,7 +64,7 @@ describe('budgetSlice', () => {
describe('updateBudgetItem', () => { describe('updateBudgetItem', () => {
it('FE-BUDGET-004: updateBudgetItem replaces item in array', async () => { 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] }); seedStore(useTripStore, { budgetItems: [item] });
server.use( 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(result.name).toBe('Updated');
expect(useTripStore.getState().budgetItems[0].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 () => { 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 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, { seedStore(useTripStore, {
budgetItems: [item], budgetItems: [item],
reservations: [initialReservation], reservations: [initialReservation],
@@ -106,7 +106,7 @@ describe('budgetSlice', () => {
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, 50));
expect(useTripStore.getState().reservations).toHaveLength(1); 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', () => { describe('toggleBudgetMemberPaid', () => {
it('FE-BUDGET-008: toggleBudgetMemberPaid updates paid status after API success', async () => { 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] }); const item = buildBudgetItem({ id: 10, trip_id: 1, members: [member] });
seedStore(useTripStore, { budgetItems: [item] }); seedStore(useTripStore, { budgetItems: [item] });
@@ -42,20 +42,20 @@ describe('reservationsSlice', () => {
describe('addReservation', () => { describe('addReservation', () => {
it('FE-RESERV-002: addReservation prepends to reservations array', async () => { 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] }); seedStore(useTripStore, { reservations: [existing] });
const result = await useTripStore.getState().addReservation(1, { const result = await useTripStore.getState().addReservation(1, {
name: 'New Hotel', title: 'New Hotel',
type: 'hotel', type: 'hotel',
status: 'pending', status: 'pending',
}); });
expect(result.name).toBe('New Hotel'); expect(result.title).toBe('New Hotel');
const reservations = useTripStore.getState().reservations; const reservations = useTripStore.getState().reservations;
expect(reservations).toHaveLength(2); expect(reservations).toHaveLength(2);
// addReservation prepends // 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 () => { it('FE-RESERV-003: addReservation on failure throws', async () => {
@@ -66,14 +66,14 @@ describe('reservationsSlice', () => {
); );
await expect( await expect(
useTripStore.getState().addReservation(1, { name: 'Fail' }) useTripStore.getState().addReservation(1, { title: 'Fail' })
).rejects.toThrow(); ).rejects.toThrow();
}); });
}); });
describe('updateReservation', () => { describe('updateReservation', () => {
it('FE-RESERV-004: updateReservation replaces item in array by id', async () => { 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] }); seedStore(useTripStore, { reservations: [reservation] });
server.use( 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(result.title).toBe('Updated Hotel');
expect(useTripStore.getState().reservations[0].name).toBe('Updated Hotel'); expect(useTripStore.getState().reservations[0].title).toBe('Updated Hotel');
}); });
}); });
+2 -2
View File
@@ -201,14 +201,14 @@ describe('tripStore', () => {
describe('updateTrip', () => { describe('updateTrip', () => {
it('FE-TRIP-008: updateTrip persists and refreshes trip + days', async () => { 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( server.use(
http.put('/api/trips/1', () => HttpResponse.json({ trip: updatedTrip })), http.put('/api/trips/1', () => HttpResponse.json({ trip: updatedTrip })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })), 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(result).toEqual(updatedTrip);
expect(useTripStore.getState().trip).toEqual(updatedTrip); expect(useTripStore.getState().trip).toEqual(updatedTrip);
+10 -5
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../../src/utils/formatters'; 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', () => { describe('currencyDecimals', () => {
it('returns 0 for zero-decimal currencies', () => { 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: '' } }, { 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', () => { 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: '' } }, { 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', () => { 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: '' } }, { 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', () => { 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: '' } }, { 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(1, asMap(assignments), 'USD')).toBeNull();
expect(dayTotalCost(2, assignments, 'USD')).toBe('10 USD'); expect(dayTotalCost(2, asMap(assignments), 'USD')).toBe('10 USD');
}); });
}); });
@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { assignmentPlaceSchema } from '../place/place.schema';
/** /**
* Assignment API contract single source of truth for the placeday itinerary * 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. * 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({ export const assignmentCreateRequestSchema = z.object({
place_id: z.union([z.number(), z.string()]), place_id: z.union([z.number(), z.string()]),
notes: z.string().nullable().optional(), notes: z.string().nullable().optional(),
+38
View File
@@ -12,6 +12,44 @@ import { z } from 'zod';
* linked reservation's metadata (and broadcasts reservation:updated). * 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({ export const budgetCreateItemRequestSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
category: z.string().optional(), category: z.string().optional(),
+34
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { assignmentSchema } from '../assignment/assignment.schema';
/** /**
* Day + day-note API contract single source of truth for the * 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. * (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({ export const dayCreateRequestSchema = z.object({
date: z.string().optional(), date: z.string().optional(),
notes: 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()); 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({ export const packingCreateItemRequestSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
category: z.string().optional(), category: z.string().optional(),
+86
View File
@@ -1,4 +1,6 @@
import { z } from 'zod'; 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 * 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()); 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 const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) }));
export type PlaceCreateRequest = z.infer<typeof placeCreateRequestSchema>; export type PlaceCreateRequest = z.infer<typeof placeCreateRequestSchema>;
@@ -15,6 +15,91 @@ import { z } from 'zod';
const open = z.record(z.string(), z.unknown()); 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. */ /** Reservation create: title is required; the many optional fields stay open. */
export const reservationCreateRequestSchema = open.and(z.object({ title: z.string().min(1) })); export const reservationCreateRequestSchema = open.and(z.object({ title: z.string().min(1) }));
export type ReservationCreateRequest = z.infer<typeof reservationCreateRequestSchema>; 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. * 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({ export const tripCreateRequestSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
description: z.string().nullable().optional(), description: z.string().nullable().optional(),