Compare commits

..

2 Commits

Author SHA1 Message Date
jubnl 2d79254c33 feat(pdf): add legs to pdf export 2026-06-17 11:05:35 +02:00
jubnl e6fcbc7789 fix(shared-view): render each leg of multi-leg flights correctly
The read-only shared view showed the overall trip start/end airports and
the first leg's flight number on every leg of a multi-leg flight. The Day
Plan already expands legs (each carries __leg), but the renderer ignored it
and read flat top-level metadata; the Bookings tab had the same bug.

- Day Plan: use __leg for per-leg airline/flight number/route, plus dep-arr time
- Bookings tab: list each leg via getFlightLegs()
- unique React keys for multi-leg rows

Closes #1219
2026-06-17 10:44:05 +02:00
686 changed files with 12297 additions and 11412 deletions
-1
View File
@@ -32,7 +32,6 @@ server/tests/
server/vitest.config.ts
server/reset-admin.js
**/*.test.ts
**/*.spec.ts
wiki/
scripts/
charts/
-4
View File
@@ -85,10 +85,6 @@ COPY --from=server-builder /app/server/dist ./server/dist
COPY --from=server-builder /app/server/assets ./server/assets
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
# raw .ts source — it never enters dist, so it must be copied in explicitly or
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0"?>
<CommunityApplications>
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<DonateText>Support TREK development</DonateText>
</CommunityApplications>
+1 -1
View File
@@ -39,7 +39,7 @@ See `values.yaml` for more options.
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
- PVCs require a default StorageClass or specify one as needed.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.1.2
version: 3.1.0
description: Minimal Helm chart for TREK app
appVersion: "3.1.2"
appVersion: "3.1.0"
-14
View File
@@ -5,16 +5,9 @@ metadata:
name: {{ include "trek.fullname" . }}-data
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.data.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.data.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.data.size }}
@@ -25,16 +18,9 @@ metadata:
name: {{ include "trek.fullname" . }}-uploads
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.uploads.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.uploads.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
-5
View File
@@ -98,13 +98,8 @@ persistence:
enabled: true
data:
size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: ""
annotations: {}
uploads:
size: 1Gi
storageClassName: ""
annotations: {}
resources:
requests:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "3.1.2",
"version": "3.1.0",
"private": true,
"type": "module",
"scripts": {
+1 -2
View File
@@ -489,7 +489,7 @@ export const addonsApi = {
export const airtrailApi = {
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
@@ -595,7 +595,6 @@ export const budgetApi = {
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
@@ -6,7 +6,6 @@ import { useToast } from '../shared/Toast'
import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
import type { Place } from '../../types'
const MAP_PRESETS = [
@@ -21,7 +20,6 @@ type Defaults = {
temperature_unit?: string
dark_mode?: string | boolean
time_format?: string
default_currency?: string
blur_booking_codes?: boolean
map_tile_url?: string
map_provider?: string
@@ -228,23 +226,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))}
</OptionRow>
{/* Default Currency */}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.currency')} <ResetButton field="default_currency" />
</label>
<CustomSelect
value={defaults.default_currency || ''}
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
placeholder={t('settings.currency')}
searchable
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
size="sm"
style={{ maxWidth: 240 }}
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
</div>
{/* Blur Booking Codes */}
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
{([
@@ -1,197 +0,0 @@
// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
import { render, screen, waitFor } from '../../../tests/helpers/render'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useAuthStore } from '../../store/authStore'
import { useTripStore } from '../../store/tripStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
import CostsPanel from './CostsPanel'
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: null },
{ id: 2, username: 'bob', avatar_url: null },
]
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) })
})
describe('CostsPanel — settlements in the ledger', () => {
it('renders a settle-up payment as a ledger row with an undo action', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [],
flows: [],
settlements: [
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
],
})
),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
// The expense and the settlement (payment) both appear in the unified ledger.
await screen.findByText('Dinner')
await screen.findByText('Payment')
// The payment row exposes an inline undo (no need to open a separate History modal).
expect(screen.getByTitle('Undo')).toBeInTheDocument()
})
it('records a manual payment via the Add payment button', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 1, ...posted } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '25')
// The footer submit is the second "Add payment" control once the modal is open.
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
const submit = addButtons[addButtons.length - 1]
await user.click(submit)
await waitFor(() => expect(posted).toMatchObject({ amount: 25 }))
})
it('hides payment rows while a text search is active', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [],
flows: [],
settlements: [
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
],
})
),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Payment')
await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner')
// Payment rows have no name, so a search hides them while the matching expense stays.
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
expect(screen.getByText('Dinner')).toBeInTheDocument()
})
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 5 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
await waitFor(() => expect(nums()[1].value).toBe('50'))
expect(nums()[2].value).toBe('50')
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
await user.clear(nums()[1]); await user.type(nums()[1], '30')
await waitFor(() => expect(nums()[2].value).toBe('70'))
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(100)
expect(posted!.payers).toEqual(expect.arrayContaining([
expect.objectContaining({ user_id: 1, amount: 30 }),
expect.objectContaining({ user_id: 2, amount: 70 }),
]))
})
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(39.99)
})
it('marks an expense with no payer as Unfinished', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Hotel')
expect(screen.getByText('Unfinished')).toBeInTheDocument()
})
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
// The participant toggles are buttons; the same names also appear as plain text in
// the Balances sidebar, so target the buttons specifically.
await user.click(screen.getByRole('button', { name: /alice/i }))
await user.click(screen.getByRole('button', { name: /bob/i }))
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
const submit = addBtns[addBtns.length - 1] // footer submit
expect(submit).not.toBeDisabled()
await user.click(submit)
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(120)
expect(posted!.member_ids).toEqual([])
expect(posted!.payers).toEqual([])
})
})
+98 -278
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -39,12 +39,6 @@ interface SettlementData {
settlements: Settlement[]
}
// One row in the unified Costs ledger — either an expense or a settle-up payment,
// carrying the date used to group it by day.
type LedgerEntry =
| { kind: 'expense'; date: string; e: BudgetItem }
| { kind: 'payment'; date: string; s: Settlement }
const round2 = (n: number) => Math.round(n * 100) / 100
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
@@ -68,10 +62,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const [settlement, setSettlement] = useState<SettlementData | null>(null)
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
const [search, setSearch] = useState('')
const [histOpen, setHistOpen] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<BudgetItem | null>(null)
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
const [addingPayment, setAddingPayment] = useState(false)
const people = tripMembers
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
@@ -129,37 +122,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return list
}, [budgetItems, filter, search, me])
// Settlements ("payments") shown inline in the ledger. They have no name, so a
// text search hides them; they're excluded from the "owed" expense filter and,
// under "mine", only show transfers I'm part of.
const filteredSettlements = useMemo(() => {
if (search.trim()) return []
if (filter === 'owed') return []
let list = settlement?.settlements || []
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
return list
}, [settlement, filter, search, me])
const dayGroups = useMemo(() => {
const entries: LedgerEntry[] = [
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
]
const labelOf = (date: string) => {
if (!date) return t('costs.noDate')
try { return new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return date }
const groups: { day: string; items: BudgetItem[] }[] = []
const labelOf = (e: BudgetItem) => {
if (!e.expense_date) return t('costs.noDate')
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
}
// Newest day first; within a day, expenses before payments (insertion order).
const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''))
const groups: { day: string; entries: LedgerEntry[] }[] = []
for (const en of sorted) {
const day = labelOf(en.date)
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
for (const e of sorted) {
const day = labelOf(e)
let g = groups.find(x => x.day === day)
if (!g) { g = { day, entries: [] }; groups.push(g) }
g.entries.push(en)
if (!g) { g = { day, items: [] }; groups.push(g) }
g.items.push(e)
}
return groups
}, [filtered, filteredSettlements, locale, t])
}, [filtered, locale, t])
// ── settle actions ──────────────────────────────────────────────────────
const settleFlow = async (fromId: number, toId: number, amount: number) => {
@@ -303,16 +280,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{search ? t('costs.noMatch') : t('costs.emptyText')}
</div>
) : dayGroups.map(g => {
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
return (
<div key={g.day} style={{ marginBottom: 22 }}>
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
</div>
</div>
)
@@ -325,13 +300,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
{canEdit && (
<button onClick={() => setAddingPayment(true)}
className="text-content-muted bg-surface-secondary border border-edge"
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={13} /> {t('costs.addPayment')}
</button>
)}
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
</button>
</div>
<SettleFlows />
</div>
@@ -357,11 +330,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
)}
{(editingSettlement || addingPayment) && (
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
)}
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
</Modal>
<style>{`
.costs-root {
@@ -467,9 +438,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
{canEdit && (
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
)}
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
</div>
<SettleFlows />
</div>
@@ -489,13 +458,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{dayGroups.length === 0
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
: dayGroups.map(g => {
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
return (
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
</div>
)
})}
@@ -523,27 +490,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const cur = curOf(e)
const payers = (e.payers || []).filter(p => p.amount > 0)
const net = round2(myPaidOf(e) - myShareOf(e))
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in.
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
<Icon size={21} />
{isMobile && isUnfinished && (
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
)}
</span>
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
{isUnfinished && !isMobile && (
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
{t('costs.unfinished')}
</span>
)}
</div>
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
{payers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
{payers.map(p => (
@@ -563,7 +514,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
</div>
@@ -580,32 +531,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)
}
// A settle-up payment as a ledger row — visually distinct from an expense, with
// inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal.
function SettlementRow({ s }: { s: Settlement }) {
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)}${personName(s.to_user_id)}`}>
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
<span className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} {personName(s.to_user_id)}</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div className="text-content" style={{ fontSize: 18, fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
{canEdit && (
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><RotateCcw size={13} /></button>
</div>
)}
</div>
</div>
)
}
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
@@ -637,16 +562,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
function CategoryBreakdown() {
const tot: Record<string, number> = {}
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
let grand = 0
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
// Bars are scaled relative to the most expensive category (the top row fills the
// bar), not to the trip grand total — makes the relative ranking readable.
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map(c => {
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
return (
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
@@ -710,75 +633,37 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
)
}
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
// ledger row and from a manual "Add payment" button, so recording "I sent money to
// X" works the same whether or not there's an outstanding expense behind it.
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
}) {
const { t } = useTranslation()
const toast = useToast()
const otherDefault = people.find(p => p.id !== me)?.id ?? me
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
const [saving, setSaving] = useState(false)
const amt = parseFloat(amount) || 0
const valid = amt > 0 && fromId !== toId
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
const save = async () => {
if (!valid) return
setSaving(true)
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data)
onSaved()
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
}
const inputCls = 'w-full bg-surface-input border border-edge text-content'
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
const total = settlements.reduce((a, s) => a + s.amount, 0)
return (
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label className={labelCls}>{t('costs.from')}</label>
<CustomSelect value={fromId} onChange={v => setFromId(String(v))} options={opts} style={{ width: '100%' }} />
</div>
<div>
<label className={labelCls}>{t('costs.to')}</label>
<CustomSelect value={toId} onChange={v => setToId(String(v))} options={opts} style={{ width: '100%' }} />
</div>
<div>
<label className={labelCls}>{t('costs.amount')}</label>
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
</div>
</Modal>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{settlements.map(s => (
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)}${name(s.to_user_id)}`}>
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
</div>
</div>
))}
</div>
</div>
)
}
// ── Add / edit expense modal ───────────────────────────────────────────────
export interface ExpensePrefill {
name?: string
category?: string
amount?: number
reservationId?: number
}
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
}) {
const { t, locale } = useTranslation()
const toast = useToast()
@@ -786,99 +671,34 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const { convert } = useExchangeRates(base)
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
const [name, setName] = useState(editing?.name || prefill?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [name, setName] = useState(editing?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
// One participant list: a person is "in" the split and may have paid an amount.
// Entering the total auto-distributes it equally across the non-pinned participants;
// touching an amount pins it and the rest rebalance so the paid amounts always sum
// back to the total. Leaving every amount blank = an unfinished expense (counts
// toward the trip total only, never settlements, until who-paid is filled in).
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(editing.total_price) : ''
if (prefill?.amount != null) return String(prefill.amount)
return ''
})
const [participants, setParticipants] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [paid, setPaid] = useState<Record<number, string>>(() => {
const [payers, setPayers] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
return m
})
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
// payer amounts load as pinned so opening an expense never reshuffles them.
const [dirty, setDirty] = useState<Set<number>>(() =>
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
const [split, setSplit] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [saving, setSaving] = useState(false)
const totalNum = parseFloat(total) || 0
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
const paidEntered = paidSum > 0
const balanced = Math.abs(paidSum - totalNum) < 0.01
const each = participants.size > 0 ? totalNum / participants.size : 0
// No participants = a recorded total with nobody to split with (e.g. a booking
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
// people only adds the who-owes-whom split on top.
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
const splitCents = (amount: number, n: number): number[] => {
if (n <= 0) return []
const cents = Math.max(0, Math.round(amount * 100))
const base = Math.floor(cents / n), rem = cents - base * n
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
}
// Recompute the non-pinned participants so every paid amount sums to the total.
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
const ids = [...parts]
const free = ids.filter(id => !dirtySet.has(id))
if (free.length === 0) return paidMap
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
const shares = splitCents(totalVal - pinnedSum, free.length)
const next = { ...paidMap }
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
return next
}
const onTotalChange = (v: string) => {
v = v.replace(',', '.')
setTotal(v)
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
}
const onPaidChange = (id: number, v: string) => {
v = v.replace(',', '.')
const nextDirty = new Set(dirty); nextDirty.add(id)
setDirty(nextDirty)
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
}
const toggleParticipant = (id: number) => {
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
else nextParts.add(id)
setParticipants(nextParts); setDirty(nextDirty)
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
}
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
const each = split.size > 0 ? payersTotal / split.size : 0
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
const save = async () => {
if (!valid) return
setSaving(true)
const payerList = [...participants]
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
.filter(p => p.amount > 0)
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
const data = {
name: name.trim(), category: cat,
// Store the actual currency the amounts were entered in; conversion to the
// viewer's display currency happens live (real rates), no manual rate.
currency,
payers: payerList, member_ids: [...participants],
payers: payerList, member_ids: [...split],
expense_date: day || null,
// Always record the entered total: the server keeps it as-is for an unfinished
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
total_price: totalNum,
// Link a freshly-created expense to its booking (create-from-booking flow).
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
@@ -908,9 +728,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
onChange={e => onTotalChange(e.target.value)}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
@@ -926,11 +744,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
</div>
{currency !== base && totalNum > 0 && (
{currency !== base && payersTotal > 0 && (
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{formatMoney(totalNum, currency, locale)}</span>
<span>{formatMoney(payersTotal, currency, locale)}</span>
<span className="text-content-faint"></span>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
<span className="text-content-faint">· {t('costs.liveRate')}</span>
</div>
)}
@@ -955,37 +773,39 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div>
<label className={labelCls}>{t('costs.whoPaid')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map((p, idx) => {
const on = participants.has(p.id)
return (
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
<button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
</button>
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
onChange={e => onPaidChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
) : (
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
)}
{people.map(p => (
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
onChange={e => setPayers(prev => ({ ...prev, [p.id]: e.target.value }))}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
</div>
))}
</div>
</div>
<div>
<label className={labelCls}>{t('costs.splitBetween')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
{people.map(p => {
const on = split.has(p.id)
return (
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
{p.id === me ? t('costs.you') : p.username}
</button>
)
})}
</div>
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<span className="text-content-faint">
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
{paidEntered
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
</div>
</div>
</div>
@@ -32,32 +32,8 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
/**
* Legacy / English free-text categories (and reservation type labels) mapped to
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
* which never matched the lowercase keys and fell through to `other`.
*/
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
transport: 'transport', transportation: 'transport',
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
grocery: 'groceries', groceries: 'groceries',
activity: 'activities', activities: 'activities',
sightseeing: 'sightseeing', sights: 'sightseeing',
shop: 'shopping', shopping: 'shopping',
fee: 'fees', fees: 'fees',
health: 'health', medical: 'health',
tip: 'tips', tips: 'tips',
other: 'other', misc: 'other',
}
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
/** Map any stored category (incl. legacy free-text values) to a known meta. */
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
if (!cat) return COST_CAT_META.other
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
return COST_CAT_META.other
}
@@ -6,7 +6,6 @@ import {
calculateSegments,
optimizeRoute,
generateGoogleMapsUrl,
withHotelBookends,
} from './RouteCalculator'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -242,46 +241,3 @@ describe('generateGoogleMapsUrl', () => {
expect(result).toContain('48.86,2.36')
})
})
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
describe('withHotelBookends', () => {
const hotel = { lat: 1, lng: 1 }
const a = { lat: 2, lng: 2 }
const b = { lat: 3, lng: 3 }
const evening = { lat: 4, lng: 4 }
it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([
[hotel, a],
[a, b],
[b, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => {
expect(withHotelBookends([], a, a, hotel, evening)).toEqual([
[hotel, a],
[a, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([
[hotel, a],
[a, b],
])
})
})
@@ -67,27 +67,6 @@ export async function calculateRoute(
}
}
/**
* Prepends a hotel→first-waypoint run and appends a last-waypoint→hotel run to the
* day's activity runs, so the drawn route starts and ends at the day's accommodation
* (matching the sidebar's hotel connectors). A bookend is only added when both its
* hotel and the first/last located waypoint exist; passing nulls leaves `runs`
* untouched. The shared first/last waypoint is repeated so the polylines join.
*/
export function withHotelBookends(
runs: Waypoint[][],
firstWay: Waypoint | undefined,
lastWay: Waypoint | undefined,
startHotel: Waypoint | null,
endHotel: Waypoint | null,
): Waypoint[][] {
const out: Waypoint[][] = []
if (startHotel && firstWay) out.push([startHotel, firstWay])
out.push(...runs)
if (endHotel && lastWay) out.push([lastWay, endHotel])
return out
}
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length === 0) return null
-22
View File
@@ -323,28 +323,6 @@ describe('downloadTripPDF', () => {
expect(photoCalled).toBe(true)
})
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
let fetchedId: string | null = null
server.use(
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
fetchedId = params.placeId as string
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
}),
)
// The assignment projection drops osm_id; the full place in `places` carries it.
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
const args = {
...richArgs,
places: [osmPlace],
assignments: {
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
} as any,
}
await downloadTripPDF(args)
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
expect(fetchedId).toBe('node/240109189')
})
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
const args = {
...minimalArgs,
+10 -18
View File
@@ -97,29 +97,21 @@ function dayCost(assignments, dayId, locale) {
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
}
// Pre-fetch place photos for all assigned places.
// Assignment places are a server-side projection that drops osm_id, so we recover
// the full place from the trip's places pool and key the photo off the same id the
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
// places fell back to category icons in the PDF even though they show photos in-app.
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
// Pre-fetch Google Place photos for all assigned places
async function fetchPlacePhotos(assignments: AssignmentsMap) {
const photoMap = {} // placeId → photoUrl
// The assignment projection drops osm_id, so recover it from the full places pool.
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
const toFetch = unique
.map(p => ({ p, osm_id: osmById.get(p.id) }))
.filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null)))
// Assignment places are a server-side projection that omits osm_id, so photo
// pre-fetch keys off the google_place_id that the projection does carry.
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
await Promise.allSettled(
toFetch.map(async ({ p, osm_id }) => {
// Same key the app UI uses: google_place_id || osm_id || coords.
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
toFetch.map(async (place) => {
try {
const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name)
if (data.photoUrl) photoMap[p.id] = data.photoUrl
const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
if (data.photoUrl) photoMap[place.id] = data.photoUrl
} catch {}
})
)
@@ -149,8 +141,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
const accommodations = await accommodationsApi.list(trip.id);
// Pre-fetch place photos (Google, OSM and coords-only places)
const photoMap = await fetchPlacePhotos(assignments, places)
// Pre-fetch place photos from Google
const photoMap = await fetchPlacePhotos(assignments)
const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
@@ -174,9 +174,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
const user = userEvent.setup();
// Uncategorized item: deleting it is a plain DELETE (a custom category's last
// item is instead converted to a placeholder — see FE-COMP-PACKING-070).
const item = buildPackingItem({ id: 99, name: 'To Remove', category: null });
const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' });
let deleteCalled = false;
server.use(
http.delete('/api/trips/1/packing/99', () => {
@@ -1417,83 +1415,4 @@ describe('PackingListPanel', () => {
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
it('FE-COMP-PACKING-070: deleting the last item of a custom category converts the row to a placeholder so the category persists in place (#1289)', async () => {
const user = userEvent.setup();
const item = buildPackingItem({ id: 99, name: 'Tent', category: 'Camping Gear' });
// handleDeleteItem decides "last in category" from the rendered list.
seedStore(useTripStore, { packingItems: [item] });
let deleted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.delete('/api/trips/1/packing/99', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/99', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[item]} />);
await user.click(screen.getByTitle('Delete'));
// The row is updated in place (same id) rather than deleted, so colour/position hold.
await waitFor(() => expect(putBody).toMatchObject({ name: '...' }));
expect(deleted).toBe(false);
});
it('FE-COMP-PACKING-071: deleting the placeholder row deletes it, dismissing the empty category (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let deleted = false;
let converted = false;
server.use(
http.delete('/api/trips/1/packing/5', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/5', () => {
converted = true;
return HttpResponse.json({ item: placeholder });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
await user.click(screen.getByTitle('Delete'));
await waitFor(() => expect(deleted).toBe(true));
// It is the placeholder itself — it must be removed, not re-converted.
expect(converted).toBe(false);
});
it('FE-COMP-PACKING-072: adding an item to an empty category reuses the placeholder row instead of appending (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let posted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/packing', () => {
posted = true;
return HttpResponse.json({ item: buildPackingItem({ id: 6 }) });
}),
http.put('/api/trips/1/packing/5', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
// Open the category's inline "Add item" and add a real entry.
await user.click(screen.getByText('Add item'));
const input = await screen.findByPlaceholderText('Item name...');
await user.type(input, 'Tent');
await user.keyboard('{Enter}');
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
expect(posted).toBe(false);
});
});
@@ -18,7 +18,6 @@ interface KategorieGruppeProps {
allCategories: string[]
onRename: (oldName: string, newName: string) => Promise<void>
onDeleteAll: (items: PackingItem[]) => Promise<void>
onDeleteItem: (item: PackingItem) => Promise<void>
onAddItem: (category: string, name: string) => Promise<void>
assignees: CategoryAssignee[]
tripMembers: TripMember[]
@@ -29,7 +28,7 @@ interface KategorieGruppeProps {
canEdit?: boolean
}
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
@@ -232,7 +231,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
))}
{/* Inline add item */}
{canEdit && (showAddItem ? (
@@ -15,14 +15,13 @@ interface ArtikelZeileProps {
tripId: number
categories: string[]
onCategoryChange: () => void
onDelete?: (item: PackingItem) => Promise<void>
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
@@ -44,9 +43,6 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
}
const handleDelete = async () => {
// The panel routes deletion through onDelete so an emptied custom category
// keeps its placeholder; fall back to a plain delete when used standalone.
if (onDelete) { await onDelete(item); return }
try { await deletePackingItem(tripId, item.id) }
catch { toast.error(t('packing.toast.deleteError')) }
}
@@ -4,7 +4,7 @@ import { KategorieGruppe } from './PackingListPanelCategoryGroup'
export function PackingList(S: PackingState) {
const {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
} = S
@@ -31,7 +31,6 @@ export function PackingList(S: PackingState) {
allCategories={allCategories}
onRename={handleRenameCategory}
onDeleteAll={handleDeleteCategory}
onDeleteItem={handleDeleteItem}
onAddItem={handleAddItemToCategory}
assignees={categoryAssignees[kat] || []}
tripMembers={tripMembers}
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
import { packingApi, tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import type { PackingItem, PackingBag } from '../../types'
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { BAG_COLORS } from './packingListPanel.constants'
import { parseImportLines } from './packingListPanel.helpers'
export interface TripMember {
@@ -44,7 +44,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
@@ -106,45 +106,10 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const handleAddItemToCategory = async (category: string, name: string) => {
try {
// Reuse the '...' placeholder slot when the category already has one, so a
// freshly-emptied category keeps its position (and therefore its colour)
// instead of the new item being appended to the end of the list.
const placeholder = useTripStore.getState().packingItems.find(
i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME
)
if (placeholder) {
await updatePackingItem(tripId, placeholder.id, { name })
} else {
await addPackingItem(tripId, { name, category })
}
await addPackingItem(tripId, { name, category })
} catch { toast.error(t('packing.toast.addError')) }
}
// Deleting an item from a row. When it is the last item of a user-created
// category, turn that row back into the '...' placeholder in place rather than
// deleting it (#1289). Updating the row keeps its id, list position and colour,
// so the category neither disappears nor jumps to the end. The default
// (uncategorized) group and the placeholder row itself are deleted normally —
// removing the placeholder is how an empty category is dismissed.
const handleDeleteItem = async (item: PackingItem) => {
const category = item.category
const isLastInCategory = !!category
&& item.name !== PACKING_PLACEHOLDER_NAME
&& !items.some(i => i.id !== item.id && i.category === category)
try {
if (isLastInCategory) {
if (item.checked) await togglePackingItem(tripId, item.id, false)
await updatePackingItem(tripId, item.id, {
name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1,
})
} else {
await deletePackingItem(tripId, item.id)
}
} catch {
toast.error(t('packing.toast.deleteError'))
}
}
const handleAddNewCategory = async () => {
if (!newCatName.trim()) return
let catName = newCatName.trim()
@@ -343,7 +308,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
@@ -1,61 +0,0 @@
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { formatMoney } from '../../utils/formatters'
import { catMeta } from '../Budget/costsCategories'
import type { BudgetItem } from '../../types'
/**
* The Costs block inside a booking modal. Replaces the old inline price + budget
* category fields: when no expense is linked yet it offers a "create expense"
* button (the modal saves the booking first, then opens the full Costs editor);
* once linked it shows the expense with edit / remove actions.
*/
export function BookingCostsSection({ reservationId, onCreate, onEdit, onRemove }: {
reservationId: number | null
onCreate: () => void
onEdit: (item: BudgetItem) => void
onRemove: (item: BudgetItem) => void
}) {
const { t, locale } = useTranslation()
const budgetItems = useTripStore(s => s.budgetItems)
const trip = useTripStore(s => s.trip)
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
if (linked) {
const meta = catMeta(linked.category)
const Icon = meta.Icon
return (
<div>
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
<div className="text-content-faint" style={{ fontSize: 12 }}>{t(meta.labelKey)}</div>
</div>
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
</div>
</div>
)
}
return (
<div>
<label className={labelCls}>{t('reservations.costsLabel')}</label>
<button type="button" onClick={onCreate}
className="bg-surface-secondary border border-edge text-content"
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 13.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={15} /> {t('reservations.createExpense')}
</button>
<div className="text-content-faint" style={{ fontSize: 11, marginTop: 6 }}>{t('reservations.createExpenseHint')}</div>
</div>
)
}
@@ -1,11 +0,0 @@
import type { BudgetItem } from '../../types'
/**
* A request from a booking modal to open the Costs expense editor — either to
* edit the already-linked expense, or to create a new one prefilled from the
* booking (the modal saves the booking first so `reservationId` is known).
*/
export interface BookingExpenseRequest {
editItem?: BudgetItem
prefill?: { reservationId?: number; name?: string; category?: string; amount?: number }
}
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
import { assignmentsApi, reservationsApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
@@ -2168,28 +2168,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<RouteIcon size={12} strokeWidth={2} />
{t('dayplan.route')}
</button>
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<button
onClick={() => {
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
title={t('planner.openGoogleMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<path d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
<path d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
<path d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
</svg>
</button>
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
@@ -399,38 +399,17 @@ describe('PlaceFormModal', () => {
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-026: time section is hidden in edit mode when no assignment is in context', () => {
// Times are per day-assignment; editing a pool place with no day in context
// (assignmentId null) hides the fields, which otherwise would not persist (#1247)
it('FE-PLANNER-PLACEFORM-026: time section IS shown in edit mode', () => {
const place = buildPlace({ name: 'Test' });
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-026b: time section IS shown when an assignment is in context', () => {
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '10:00' });
const assignment = buildAssignment({ id: 10, day_id: 5, place });
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
// Time pickers are rendered when editing
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
});
it('FE-PLANNER-PLACEFORM-026c: hydrates Start/End from the assignment when the pool place lacks times (#1247)', () => {
// The pool Place carries no times — they live on the day-assignment. Opening the
// editor with an assignmentId must hydrate the fields from assignment.place, not
// the (timeless) pool place that the Places panel passes in.
const poolPlace = buildPlace({ id: 7, name: 'Museum' });
const assignmentPlace = buildPlace({ id: 7, name: 'Museum', place_time: '20:20', end_time: '20:34' });
const assignment = buildAssignment({ id: 42, day_id: 3, place: assignmentPlace });
render(<PlaceFormModal {...defaultProps} place={poolPlace} assignmentId={42} dayAssignments={[assignment]} />);
expect(screen.getByDisplayValue('20:20')).toBeInTheDocument();
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
// Build an assignment whose place has end_time before place_time
// Build a place with end_time before place_time
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
const assignment = buildAssignment({ id: 11, day_id: 5, place });
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={11} dayAssignments={[assignment]} />);
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
// hasTimeError = true → submit button disabled
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
@@ -92,11 +92,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
useEffect(() => {
if (place) {
// Times are stored per day-assignment, not on the pool place. When an
// assignment is in context (itinerary edit, or a single-assignment pool
// edit) read the times off its embedded place; fall back to the place prop.
const assignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
const timeSource = assignment?.place ?? place
setForm({
name: place.name || '',
description: place.description || '',
@@ -104,8 +99,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lat: place.lat != null ? String(place.lat) : '',
lng: place.lng != null ? String(place.lng) : '',
category_id: place.category_id != null ? String(place.category_id) : '',
place_time: timeSource.place_time || '',
end_time: timeSource.end_time || '',
place_time: place.place_time || '',
end_time: place.end_time || '',
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
@@ -126,10 +121,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
setPendingFiles([])
setDuplicateWarning(null)
// dayAssignments is a fresh array each render; read it at open-time only and
// re-run on identity changes (place/assignmentId/open), not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [place, prefillCoords, isOpen, assignmentId])
}, [place, prefillCoords, isOpen])
// Derive location bias bounding box from the trip's existing places
const places = useTripStore((s) => s.places)
@@ -736,11 +728,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
)}
</div>
{/* Time is per day-assignment: only shown when a single assignment is in
context (itinerary edit, or a single-assignment pool edit). Hidden when
creating, and for unassigned / multi-day pool edits where a single time
is ambiguous and wouldn't persist. */}
{place && assignmentId && (
{/* Time — only shown when editing, not when creating */}
{place && (
<TimeSection
form={form}
handleChange={handleChange}
@@ -343,51 +343,56 @@ describe('ReservationModal', () => {
// ── Budget addon ─────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-024: costs section (create expense) visible when budget addon is enabled', () => {
it('FE-PLANNER-RESMODAL-024: budget section visible when budget addon is enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<ReservationModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-025: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
it('FE-PLANNER-RESMODAL-025: budget price input accepts valid decimal', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue({ id: 55 });
const onOpenExpense = vi.fn();
render(<ReservationModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
render(<ReservationModal {...defaultProps} />);
const priceInput = screen.getByPlaceholderText('0.00');
await userEvent.type(priceInput, '99.99');
expect((priceInput as HTMLInputElement).value).toBe('99.99');
});
it('FE-PLANNER-RESMODAL-026: budget hint shown when price > 0', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<ReservationModal {...defaultProps} />);
const priceInput = screen.getByPlaceholderText('0.00');
await userEvent.type(priceInput, '50');
expect(screen.getByText(/budget entry will be created/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-027: budget fields included in onSave when price is set', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await userEvent.type(screen.getByPlaceholderText('0.00'), '120');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
await waitFor(() =>
expect(onOpenExpense).toHaveBeenCalledWith(
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 55 }) })
)
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 120 }) })
);
});
it('FE-PLANNER-RESMODAL-026: linked expense summary shown for a booking with a linked cost', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: 'EUR', category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
],
});
render(<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />);
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
});
// ── File upload ───────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
@@ -594,6 +599,22 @@ describe('ReservationModal', () => {
expect(filePickerItem).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-044: budget category dropdown options include existing categories', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 1, trip_id: 1, name: 'Flight ticket', total_price: 300, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
],
});
render(<ReservationModal {...defaultProps} />);
// Budget section is visible
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
@@ -611,6 +632,31 @@ describe('ReservationModal', () => {
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
});
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 1, trip_id: 1, name: 'Ticket', total_price: 100, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
],
});
render(<ReservationModal {...defaultProps} />);
// Open the budget category CustomSelect (shows placeholder "Auto (from booking type)")
const budgetCategoryBtn = screen.getByText(/Auto \(from booking type\)/i).closest('button')!;
await userEvent.click(budgetCategoryBtn);
// Click the "Transport" category option
await waitFor(() => expect(screen.getByText('Transport')).toBeInTheDocument());
await userEvent.click(screen.getByText('Transport'));
// The select should now show "Transport"
expect(screen.getByText('Transport')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
render(<ReservationModal {...defaultProps} />);
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
@@ -11,10 +11,7 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { openFile } from '../../utils/fileDownload'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import { typeToCostCategory } from '@trek/shared'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
const TYPE_OPTIONS = [
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
@@ -63,10 +60,9 @@ interface ReservationModalProps {
onFileDelete: (fileId: number) => Promise<void>
accommodations?: Accommodation[]
defaultAssignmentId?: number | null
onOpenExpense?: (req: BookingExpenseRequest) => void
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense }: ReservationModalProps) {
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
const { id: tripId } = useParams<{ id: string }>()
const loadFiles = useTripStore(s => s.loadFiles)
const toast = useToast()
@@ -74,14 +70,18 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const fileInputRef = useRef(null)
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
// Set right before submit when the user clicked create/edit expense (see TransportModal).
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
price: '', budget_category: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
})
@@ -127,12 +127,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
price: '', budget_category: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
})
@@ -164,8 +167,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
return endFull <= startFull
})()
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
e?.preventDefault?.()
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.title.trim()) return
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
setIsSaving(true)
@@ -182,6 +185,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
} else if (form.reservation_end_time && form.reservation_time) {
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const saveData: Record<string, any> & { title: string } = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
@@ -194,6 +202,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
endpoints: [],
needs_review: false,
}
if (isBudgetEnabled) {
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = {
place_id: form.hotel_place_id || null,
@@ -215,25 +228,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
await onFileUpload(fd)
}
}
// Open the Costs editor for the saved booking when the user asked to
// create/edit its linked expense (gated on saved?.id).
const intent = expenseIntentRef.current
expenseIntentRef.current = null
if (intent && onOpenExpense && saved?.id) {
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
}
} finally {
setIsSaving(false)
}
}
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
const handleRemoveExpense = async (item: BudgetItem) => {
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
}
const handleFileChange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
@@ -611,14 +610,38 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Costs — create / view the expense linked to this booking */}
{/* Price + Budget Category */}
{isBudgetEnabled && (
<BookingCostsSection
reservationId={reservation?.id ?? null}
onCreate={handleCreateExpense}
onEdit={handleEditExpense}
onRemove={handleRemoveExpense}
/>
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
className={inputClass} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
</form>
@@ -132,37 +132,34 @@ describe('TransportModal', () => {
// ── Budget addon ─────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-011: costs section (create expense) visible when budget addon is enabled', () => {
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<TransportModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-012: costs section not shown when budget addon is disabled', () => {
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-013: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue({ id: 42 });
const onOpenExpense = vi.fn();
render(<TransportModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
// The legacy auto-budget mechanism is gone; the expense is created via the editor instead.
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
await waitFor(() =>
expect(onOpenExpense).toHaveBeenCalledWith(
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 42 }) })
)
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
);
});
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal'
@@ -13,11 +13,8 @@ import { useAddonStore } from '../../store/addonStore'
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import { typeToCostCategory } from '@trek/shared'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
@@ -108,6 +105,8 @@ const defaultForm = {
arrival_time: '',
confirmation_number: '',
notes: '',
price: '',
budget_category: '',
meta_airline: '',
meta_flight_number: '',
meta_train_number: '',
@@ -125,20 +124,20 @@ interface TransportModalProps {
files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<unknown>
onFileDelete?: (fileId: number) => Promise<void>
onOpenExpense?: (req: BookingExpenseRequest) => void
}
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense }: TransportModalProps) {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
const loadFiles = useTripStore(s => s.loadFiles)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const { id: tripId } = useParams<{ id: string }>()
// Set right before submitting when the user clicked "create/edit expense", so
// the post-save handler knows to open the Costs editor for the saved booking.
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
@@ -178,6 +177,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
if (type === 'flight') {
const orderedEps = orderedEndpoints(reservation)
@@ -228,8 +229,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.title.trim()) return
setIsSaving(true)
try {
@@ -288,6 +289,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
@@ -328,6 +334,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints,
needs_review: false,
}
if (isBudgetEnabled) {
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
const saved = await onSave(payload)
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
for (const file of pendingFiles) {
@@ -338,14 +349,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
await onFileUpload(fd)
}
}
// The user asked to create/edit the linked expense — open the Costs editor
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
const intent = expenseIntentRef.current
expenseIntentRef.current = null
if (intent && onOpenExpense && saved?.id) {
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
}
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} finally {
@@ -353,12 +356,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
}
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
const handleRemoveExpense = async (item: BudgetItem) => {
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
@@ -715,14 +712,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</div>
</div>
{/* Costs — create / view the expense linked to this booking */}
{/* Price + Budget Category */}
{isBudgetEnabled && (
<BookingCostsSection
reservationId={reservation?.id ?? null}
onCreate={handleCreateExpense}
onEdit={handleEditExpense}
onRemove={handleRemoveExpense}
/>
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
className={inputClass} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
</form>
@@ -19,7 +19,6 @@ export default function AirTrailConnectionSection(): React.ReactElement {
const [url, setUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
const [writeEnabled, setWriteEnabled] = useState(false)
const [connected, setConnected] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -31,7 +30,6 @@ export default function AirTrailConnectionSection(): React.ReactElement {
.then(d => {
setUrl(d.url || '')
setAllowInsecureTls(!!d.allowInsecureTls)
setWriteEnabled(!!d.writeEnabled)
setConnected(!!d.connected)
})
.catch(() => {})
@@ -48,7 +46,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
const handleSave = async () => {
setSaving(true)
try {
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, writeEnabled, ...keyPayload() })
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, ...keyPayload() })
const status = await airtrailApi.status().catch(() => ({ connected: false }))
setConnected(!!status.connected)
setApiKey('')
@@ -109,14 +107,6 @@ export default function AirTrailConnectionSection(): React.ReactElement {
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
</div>
<div>
<div className="flex items-center gap-3">
<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} />
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.writeBack')}</span>
</div>
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.writeBackHint')}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={handleSave}
@@ -69,7 +69,7 @@ export default function VacayCalendar() {
return (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" style={{ paddingBottom: 'calc(var(--bottom-nav-h, 0px) + 80px)' }}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
{Array.from({ length: 12 }, (_, i) => (
<VacayMonthCard
key={i}
@@ -89,8 +89,8 @@ export default function VacayCalendar() {
))}
</div>
{/* Floating toolbar — lift above the mobile bottom nav (z-60). On desktop --bottom-nav-h is 0px. */}
<div className="sticky mt-3 sm:mt-4 flex items-center justify-center px-2" style={{ bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', zIndex: 61 }}>
{/* Floating toolbar */}
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
<button
onClick={() => setCompanyMode(false)}
+1 -3
View File
@@ -102,9 +102,7 @@ export function ToastContainer() {
`}</style>
<div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
// Above modal overlays (which sit around z-index 10000 with a backdrop-filter
// blur) so error toasts paint on top and stay legible instead of blurred behind.
zIndex: 100000, display: 'flex', flexDirection: 'column-reverse', gap: 8,
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
}}>
{toasts.map(toast => (
+8 -31
View File
@@ -1,30 +1,23 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
import { calculateRouteWithLegs, withHotelBookends } from '../components/Map/RouteCalculator'
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
import { getTransportRouteEndpoints } from '../utils/dayMerge'
import { getDayBookendHotels } from '../utils/dayOrder'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult, Accommodation } from '../types'
import type { RouteSegment, RouteResult } from '../types'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
const NO_ACCOMMODATIONS: Accommodation[] = []
/**
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
*/
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
const [route, setRoute] = useState<[number, number][][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations)
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
// hotel) unless the user turned the setting off — same gate as the sidebar.
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation)
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
@@ -100,26 +93,10 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
}
if (currentRun.length >= 2) runs.push(currentRun)
// Bookend the route with the day's accommodation: a hotel → first-stop run and
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
// transfer day) and already filters to accommodations that have coordinates.
const day = allDays.find(d => d.id === dayId)
const { morning: startHotel, evening: endHotel } =
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, allDays, accommodations) : {}
const flatPts: { lat: number; lng: number }[] = []
for (const e of entries) {
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng })
else { if (e.from) flatPts.push(e.from); if (e.to) flatPts.push(e.to) }
}
const hotelPt = (a?: Accommodation) =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null
const runsWithHotel = withHotelBookends(runs, flatPts[0], flatPts[flatPts.length - 1], hotelPt(startHotel), hotelPt(endHotel))
const straightLines = (): [number, number][][] =>
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); return }
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
// Draw straight lines immediately for snappiness, then upgrade to the real
// OSRM road geometry.
@@ -130,7 +107,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
try {
const polylines: [number, number][][] = []
const allLegs: RouteSegment[] = []
for (const run of runsWithHotel) {
for (const run of runs) {
try {
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
@@ -146,7 +123,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
}
}, [enabled, profile, accommodations, optimizeFromAccommodation])
}, [enabled, profile])
// Stable signature for transport reservations on the selected day — changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
@@ -170,7 +147,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation])
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
+2 -7
View File
@@ -6,7 +6,6 @@ import CustomSelect from '../components/shared/CustomSelect'
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
import type { TranslationFn } from '../types'
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { continentForCountry } from '@trek/shared'
import { useAtlas } from './atlas/useAtlas'
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
import { useToast } from '../components/shared/Toast'
@@ -213,8 +212,7 @@ export default function AtlasPage(): React.ReactElement {
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
setData(prev => {
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
const cont = continentForCountry(confirmAction.code)
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
})
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
@@ -262,8 +260,7 @@ export default function AtlasPage(): React.ReactElement {
})
setData(prev => {
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
const cont = continentForCountry(countryCode)
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
})
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
@@ -342,12 +339,10 @@ export default function AtlasPage(): React.ReactElement {
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
if (remainingRegions.length > 0) return prev
const cont = continentForCountry(countryCode)
return {
...prev,
countries: prev.countries.filter(c => c.code !== countryCode),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
}
})
} catch (err) {
+4 -28
View File
@@ -18,8 +18,6 @@ import {
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
LayoutGrid, List, Ticket, X,
} from 'lucide-react'
import { formatTime, splitReservationDateTime } from '../utils/formatters'
import { useSettingsStore } from '../store/settingsStore'
import '../styles/dashboard.css'
const GRADIENTS = [
@@ -38,7 +36,6 @@ function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.leng
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string } | null {
if (!dateStr) return null
const date = new Date(dateStr + 'T00:00:00Z')
if (isNaN(date.getTime())) return null // malformed date — render a dash, never crash
return {
d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }),
m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }),
@@ -84,7 +81,6 @@ export default function DashboardPage(): React.ReactElement {
const {
demoMode, locale, t, navigate,
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
loadError, retryLoad,
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
@@ -103,15 +99,6 @@ export default function DashboardPage(): React.ReactElement {
<MobileTopBar />
<main className="page">
<div className="page-main">
{loadError && (
<div className="dash-error" role="alert">
<span className="dash-error-txt">{t('dashboard.loadErrorBanner')}</span>
<button className="dash-error-retry" onClick={retryLoad}>
<RefreshCw size={15} />
{t('dashboard.retry')}
</button>
</div>
)}
{spotlight && (
<BoardingPassHero
trip={spotlight}
@@ -142,13 +129,6 @@ export default function DashboardPage(): React.ReactElement {
</div>
</div>
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
<div className="trips-empty">
<h4>{t('dashboard.emptyTitle')}</h4>
<p>{t('dashboard.emptyText')}</p>
</div>
)}
<div className={`trips${viewMode === 'list' ? ' list-view' : ''}`}>
{gridTrips.map(trip => (
<TripCard
@@ -622,7 +602,6 @@ function UpcomingTool({ items, locale, onOpen }: {
items: UpcomingReservation[]; locale: string; onOpen: (tripId: number) => void
}): React.ReactElement {
const { t } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format)
return (
<div className="tool">
<div className="tool-head">
@@ -633,13 +612,10 @@ function UpcomingTool({ items, locale, onOpen }: {
) : (
<div className="upc-list">
{items.map(r => {
// Read the date/time straight from the stored string parts. Going through
// new Date(...).toISOString() reinterprets the naive local time as UTC and
// can roll the displayed day forward/back in non-UTC timezones.
const parsed = splitReservationDateTime(r.reservation_time)
const datePart = parsed.date || r.day_date || null
const dateStr = datePart ? splitDate(datePart, locale) : null
const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null
const when = r.reservation_time || (r.day_date ? r.day_date + 'T00:00:00' : null)
const d = when ? new Date(when) : null
const dateStr = d ? splitDate(d.toISOString().slice(0, 10), locale) : null
const timeStr = r.reservation_time ? new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : null
const typeClass = RES_TYPE_CLASS[r.type] || 'other'
return (
<div className="upc-item" key={r.id} onClick={() => onOpen(r.trip_id)}>
+5 -12
View File
@@ -1160,13 +1160,10 @@ describe('TripPlannerPage', () => {
});
describe('FE-PAGE-PLANNER-041: handleSaveReservation edit path covers update reservation', () => {
it('does not force a day_id on edit so the server keeps/derives it (#1237)', async () => {
it('calls onEdit then onSave on ReservationModal to exercise the edit-reservation handler', async () => {
vi.useFakeTimers();
seedTripStore({ id: 42 });
// Capture the update payload — tripActions is a snapshot of the store at mount.
const updateReservationSpy = vi.fn().mockResolvedValue({ id: 1, day_id: 7 });
seedStore(useTripStore, { updateReservation: updateReservationSpy } as any);
renderPlannerPage(42);
@@ -1182,24 +1179,20 @@ describe('TripPlannerPage', () => {
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
});
// Edit a reservation that lives on day 7 (no day is selected — Book tab).
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'other', status: 'confirmed', day_id: 7 };
// Set editingReservation via captured onEdit prop (inline lambda in JSX)
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'restaurant', status: 'confirmed' };
await act(async () => {
capturedReservationsPanelProps.current.onEdit?.(fakeReservation);
});
// Call onSave — now takes edit path (editingReservation is set)
await act(async () => {
await capturedReservationModalProps.current.onSave?.({
name: 'Updated Booking',
type: 'tour',
type: 'restaurant',
status: 'confirmed',
});
});
// The client must NOT send a day_id (no forcing to the selected day, no
// stale value) — the server keeps/derives it from the booking's date.
expect(updateReservationSpy).toHaveBeenCalled();
expect(updateReservationSpy.mock.calls[0][2]).not.toHaveProperty('day_id');
});
});
+30 -35
View File
@@ -25,9 +25,7 @@ import PackingListPanel from '../components/Packing/PackingListPanel'
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
import TodoListPanel from '../components/Todo/TodoListPanel'
import FileManager from '../components/Files/FileManager'
import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Budget/CostsPanel'
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
import type { BudgetItem } from '../types'
import CostsPanel from '../components/Budget/CostsPanel'
import CollabPanel from '../components/Collab/CollabPanel'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
@@ -203,7 +201,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
@@ -214,18 +212,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
// page level so it has tripMembers / base currency / current user available.
const meId = useAuthStore(s => s.user?.id ?? -1)
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
const loadBudgetItems = useTripStore(s => s.loadBudgetItems)
const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null)
const openBookingExpense = (req: BookingExpenseRequest) => {
if (req.editItem) setBookingExpense({ editing: req.editItem })
else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill })
}
if (isLoading || !splashDone) {
return (
<div className="bg-surface" style={{
@@ -465,7 +451,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onPlaceClick={handlePlaceClick}
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
onAssignToDay={handleAssignToDay}
onEditPlace={(place) => openPlaceEditor(place)}
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
onCategoryFilterChange={setMapCategoryFilter}
@@ -531,7 +517,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
assignments={assignments}
reservations={reservations}
onClose={() => setSelectedPlaceId(null)}
onEdit={() => openPlaceEditor(selectedPlace, selectedAssignmentId)}
onEdit={() => {
if (selectedAssignmentId) {
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
setEditingPlace(placeWithAssignmentTimes)
} else {
setEditingPlace(selectedPlace)
}
setEditingAssignmentId(selectedAssignmentId || null)
setShowPlaceForm(true)
}}
onDelete={() => handleDeletePlace(selectedPlace.id)}
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
@@ -569,7 +565,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
assignments={assignments}
reservations={reservations}
onClose={() => setSelectedPlaceId(null)}
onEdit={() => { openPlaceEditor(selectedPlace, selectedAssignmentId); setSelectedPlaceId(null) }}
onEdit={() => {
if (selectedAssignmentId) {
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
setEditingPlace(placeWithAssignmentTimes)
} else {
setEditingPlace(selectedPlace)
}
setEditingAssignmentId(selectedAssignmentId || null)
setShowPlaceForm(true)
setSelectedPlaceId(null)
}}
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
@@ -610,7 +617,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
</div>
@@ -696,23 +703,11 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
</div>
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
{bookingExpense && (
<ExpenseModal
tripId={tripId}
base={costsBase}
people={tripMembers}
me={meId}
editing={bookingExpense.editing}
prefill={bookingExpense.prefill}
onClose={() => setBookingExpense(null)}
onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }}
/>
)}
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
<ConfirmDialog
+5 -18
View File
@@ -229,24 +229,12 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{(updateInfo?.is_docker === false ? t('admin.update.nonDockerText') : t('admin.update.dockerText')).replace('{version}', `v${updateInfo?.latest ?? ''}`)}
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
</p>
{updateInfo?.is_docker === false ? (
<a
href="https://github.com/mauriceboe/TREK/wiki/Updating"
target="_blank"
rel="noopener noreferrer"
style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.5, display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none' }}
className="bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<ExternalLink className="w-4 h-4 flex-shrink-0" />
<span className="font-semibold underline">{t('admin.update.wikiLink')}</span>
</a>
) : (
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
{`docker pull mauriceboe/trek:latest
docker stop trek && docker rm trek
docker run -d --name trek \\
@@ -255,8 +243,7 @@ docker run -d --name trek \\
-v /opt/trek/uploads:/app/uploads \\
--restart unless-stopped \\
mauriceboe/trek:latest`}
</div>
)}
</div>
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
+4 -29
View File
@@ -6,7 +6,6 @@ import apiClient, { mapsApi } from '../../api/client'
import L from 'leaflet'
import type { GeoJsonFeatureCollection } from '../../types'
import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
import { continentForCountry } from '@trek/shared'
function useCountryNames(language: string): (code: string) => string {
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
@@ -134,12 +133,9 @@ export function useAtlas() {
}, [])
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
// no third-party fetch from the browser). Even gzipped the payload is a few MB, so
// it gets a longer timeout than the global 8s default to survive slow links and
// reverse-proxy / Cloudflare-Tunnel setups instead of aborting and leaving the map
// with no countries (#1254).
// no third-party fetch from the browser).
useEffect(() => {
apiClient.get('/addons/atlas/countries/geo', { timeout: 30000 })
apiClient.get('/addons/atlas/countries/geo')
.then(res => {
const geo = res.data
// Dynamically build A2→A3 mapping from GeoJSON
@@ -344,10 +340,7 @@ export function useAtlas() {
</div>
</div>`
layer.bindTooltip(tooltipHtml, {
// sticky so the tooltip tracks the cursor; non-sticky anchors it at the feature's
// bounds centre, which for countries with overseas territories (e.g. France) lands
// far out in the ocean instead of over the area being hovered.
sticky: true, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => {
if (c.placeCount === 0 && c.tripCount === 0) {
@@ -370,7 +363,7 @@ export function useAtlas() {
country_layer_by_a2_ref.current[countryCode] = layer
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
sticky: true, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => handleMarkCountry(countryCode, name))
layer.on('mouseover', (e) => {
@@ -559,20 +552,6 @@ export function useAtlas() {
} catch (e ) {
console.error('Error fitting bounds', e)
}
// Mirror the map-click behaviour so an already-visited country can be removed
// straight from search. Tiny countries (Vatican City, Singapore) are hard to
// hit on the map, so search was the only way in — but it always opened the
// "Mark / Bucket" dialog with no Remove option.
const visited = data?.countries.find(c => c.code === country_code)
if (visited) {
if (visited.placeCount === 0 && visited.tripCount === 0) {
handleUnmarkCountry(country_code)
} else {
loadCountryDetailRef.current(country_code)
}
return
}
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
}
@@ -586,12 +565,10 @@ export function useAtlas() {
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
setData(prev => {
if (!prev || prev.countries.find(c => c.code === code)) return prev
const cont = continentForCountry(code)
return {
...prev,
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 },
}
})
} else {
@@ -602,12 +579,10 @@ export function useAtlas() {
if (!prev) return prev
const c = prev.countries.find(c => c.code === code)
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
const cont = continentForCountry(code)
return {
...prev,
countries: prev.countries.filter(c => c.code !== code),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
}
})
setVisitedRegions(prev => {
+1 -12
View File
@@ -33,7 +33,6 @@ export function useDashboard() {
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
const [loadError, setLoadError] = useState<boolean>(false)
const [stats, setStats] = useState<TravelStats | null>(null)
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
@@ -43,7 +42,7 @@ export function useDashboard() {
const [searchParams, setSearchParams] = useSearchParams()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode, authCheckFailed, loadUser } = useAuthStore()
const { demoMode } = useAuthStore()
const toggleViewMode = () => {
setViewMode(prev => {
@@ -75,22 +74,13 @@ export function useDashboard() {
const { trips, archivedTrips } = await tripRepo.list()
setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips))
setLoadError(false)
} catch {
setLoadError(true)
toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false)
}
}
// Re-run both the trip fetch and the auth check so a recovered backend clears
// the error banner (loadUser resets authCheckFailed on success). #1283
const retryLoad = () => {
loadUser({ silent: true })
loadTrips()
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
@@ -187,7 +177,6 @@ export function useDashboard() {
demoMode, locale, t, navigate,
// data + derived
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
loadError: loadError || authCheckFailed, retryLoad,
// ui state
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
@@ -1,25 +0,0 @@
import { describe, it, expect } from 'vitest'
import { resolvePoolAssignmentId } from './tripPlannerModel'
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'
describe('resolvePoolAssignmentId', () => {
it('returns the lone assignment id when the place is assigned to exactly one day', () => {
const place = buildPlace({ id: 7 })
const assignment = buildAssignment({ id: 42, day_id: 3, place })
const assignments = { 3: [assignment], 4: [buildAssignment({ id: 99, day_id: 4 })] }
expect(resolvePoolAssignmentId(assignments, 7)).toBe(42)
})
it('returns null when the place is not assigned to any day', () => {
const assignments = { 3: [buildAssignment({ id: 99, day_id: 3 })] }
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
})
it('returns null when the place is assigned to multiple days (ambiguous time)', () => {
const assignments = {
3: [buildAssignment({ id: 1, day_id: 3, place: buildPlace({ id: 7 }) })],
4: [buildAssignment({ id: 2, day_id: 4, place: buildPlace({ id: 7 }) })],
}
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
})
})
@@ -1,24 +0,0 @@
/**
* Trip planner pure helpers React/IO-free logic shared by the data hook
* (useTripPlanner) and kept here so it can be unit-tested in isolation. Part of
* the FE "page = wiring container + data hook" convention (see PATTERN.md).
*/
import type { Assignment } from '../../types'
/**
* Resolve the day-assignment to use when a place is edited from the Places pool,
* where no day is in context. Times live per day-assignment (#1247), so we can
* only hydrate/persist a place's time when it is assigned to exactly one day.
* Returns that assignment's id, or null when the place has 0 or 2+ assignments
* (ambiguous the modal then hides the time fields).
*/
export function resolvePoolAssignmentId(
assignments: Record<string | number, Assignment[]>,
placeId: number,
): number | null {
const matches = Object.values(assignments)
.flat()
.filter((a) => a.place?.id === placeId)
return matches.length === 1 ? matches[0].id : null
}
+3 -19
View File
@@ -18,7 +18,6 @@ import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
import { resolvePoolAssignmentId } from './tripPlannerModel'
/**
* Trip planner page logic the big one. Owns the trip store wiring, addon
@@ -289,7 +288,7 @@ export function useTripPlanner() {
})
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
const changed = dayId !== selectedDayId
@@ -424,16 +423,6 @@ export function useTripPlanner() {
}
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
// Open the place editor from any entry point (Places pool, inspector, map).
// Times live per day-assignment, so when no day is in context resolve the
// place's lone assignment to hydrate & persist its times; with 0 or 2+
// assignments the time is ambiguous and the modal hides the fields (#1247).
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
setEditingPlace(place)
setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id))
setShowPlaceForm(true)
}, [assignments])
const handleDeletePlace = useCallback((placeId) => {
setDeletePlaceId(placeId)
}, [])
@@ -579,12 +568,7 @@ export function useTripPlanner() {
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
try {
if (editingReservation) {
// Don't force a day here. The old code pinned it to the (often empty)
// selected day, which dropped the booking out of the Plan; preserving the
// old day_id instead left it stale when the date changed. Omitting it lets
// the server derive the day from the booking's date, or keep the current
// one when there is no date.
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false)
setEditingReservation(null)
@@ -701,7 +685,7 @@ export function useTripPlanner() {
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
+5 -23
View File
@@ -25,11 +25,6 @@ interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
/** The auth check (loadUser) failed for a non-401 reason while we were online
* the server was unreachable or erroring. Surfaced by the UI so a backend/IdP
* outage doesn't render as a blank, error-free page that looks like lost data.
* Transient, never persisted. #1283 */
authCheckFailed: boolean
error: string | null
demoMode: boolean
devMode: boolean
@@ -91,7 +86,6 @@ export const useAuthStore = create<AuthState>()(
user: null,
isAuthenticated: false,
isLoading: true,
authCheckFailed: false,
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
devMode: false,
@@ -206,7 +200,6 @@ export const useAuthStore = create<AuthState>()(
set({
user: null,
isAuthenticated: false,
authCheckFailed: false,
error: null,
})
},
@@ -222,33 +215,22 @@ export const useAuthStore = create<AuthState>()(
user: data.user,
isAuthenticated: true,
isLoading: false,
authCheckFailed: false,
})
await onAuthSuccess(data.user.id)
connect()
} catch (err: unknown) {
if (seq !== authSequence) return // stale response — ignore
const status = err && typeof err === 'object' && 'response' in err
? (err as { response?: { status?: number } }).response?.status
: undefined
if (status === 401) {
// Invalid/expired token — clear auth so the guard redirects to login.
// Only clear auth state on 401 (invalid/expired token), not on network errors
const isAuthError = err && typeof err === 'object' && 'response' in err &&
(err as { response?: { status?: number } }).response?.status === 401
if (isAuthError) {
set({
user: null,
isAuthenticated: false,
isLoading: false,
authCheckFailed: false,
})
} else if (status === undefined && typeof navigator !== 'undefined' && !navigator.onLine) {
// Genuinely offline — keep the persisted session so the PWA serves cached
// data without a scary error. This is the offline-first happy path.
set({ isLoading: false })
} else {
// Server erroring (5xx) or unreachable while we're online: keep the session
// (don't eject the user over a transient outage), but flag it so the UI can
// say "couldn't reach the server" instead of showing a blank, error-free
// page that looks like the user's trips were lost. #1283
set({ isLoading: false, authCheckFailed: true })
set({ isLoading: false })
}
}
},
+2 -29
View File
@@ -218,7 +218,7 @@
opacity: .88; margin-bottom: 16px; font-weight: 500;
}
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4); }
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; }
/* ----------------- boarding pass ----------------- */
.trek-dash .hero-pass {
@@ -422,7 +422,7 @@
.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); }
.trek-dash .trip-action-btn svg { width: 16px; height: 16px; }
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; }
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
.trek-dash .trip-body { padding: 18px 20px 20px; }
@@ -456,33 +456,6 @@
.trek-dash .add-trip-card .ttl { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
/* Error banner shown when the trip list or the auth check couldn't reach the
server, so a backend/IdP outage no longer looks like an empty (lost-data)
dashboard. Amber rather than red: it reassures (data is safe) more than it alarms. */
.trek-dash .dash-error {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 14px 18px; margin-bottom: 22px;
background: oklch(0.74 0.14 75 / 0.13);
border: 1px solid oklch(0.74 0.14 75 / 0.45);
border-radius: var(--r-md);
box-shadow: var(--sh-sm);
}
.trek-dash .dash-error-txt { flex: 1; min-width: 200px; font-size: 14px; color: var(--ink); }
.trek-dash .dash-error-retry {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 14px; border: none; border-radius: var(--r-xs);
background: var(--ink); color: var(--surface);
font-size: 13px; font-weight: 500; cursor: pointer;
transition: opacity .15s ease;
}
.trek-dash .dash-error-retry:hover { opacity: .88; }
/* Empty state a genuine "you have no trips yet" message, visually distinct
from the error banner above so an outage and a real empty list never look alike. */
.trek-dash .trips-empty { margin-bottom: 18px; }
.trek-dash .trips-empty h4 { font-size: 18px; font-weight: 600; color: var(--ink); margin: 0 0 6px; }
.trek-dash .trips-empty p { font-size: 14px; color: var(--ink-3); margin: 0; }
/* ----------------- tools sidebar ----------------- */
.trek-dash .tool {
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
@@ -6,16 +6,13 @@ import { buildAssignment, buildPlace } from '../../helpers/factories';
import type { TripStoreState } from '../../../src/store/tripStore';
import type { RouteSegment } from '../../../src/types';
vi.mock('../../../src/components/Map/RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('../../../src/components/Map/RouteCalculator')>();
return {
...actual,
calculateRouteWithLegs: vi.fn(),
calculateRoute: vi.fn(),
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
generateGoogleMapsUrl: vi.fn(),
};
});
// Mock the RouteCalculator module to avoid real OSRM fetch calls
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
calculateRouteWithLegs: vi.fn(),
calculateRoute: vi.fn(),
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
generateGoogleMapsUrl: vi.fn(),
}));
const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator');
+1415 -1221
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "3.1.2",
"version": "3.1.0",
"workspaces": [
"client",
"server",
@@ -30,8 +30,7 @@
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
"overrides": {
"react": "19.2.6",
"react-dom": "19.2.6",
"multer": "^2.2.0"
"react-dom": "19.2.6"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-musl": "4.62.0",
-2
View File
@@ -35,8 +35,6 @@ OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes
DEMO_MODE=false # Demo mode - resets data hourly
# BACKUP_UPLOAD_LIMIT_MB=500 # Max size (MB) of a backup archive you can upload when restoring. Raise it if your backup exceeds 500 MB. If you sit behind a reverse proxy, raise its upload limit too (e.g. nginx client_max_body_size).
# MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
Binary file not shown.
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/server",
"version": "3.1.2",
"version": "3.1.0",
"main": "src/index.ts",
"scripts": {
"start": "node --require tsconfig-paths/register dist/index.js",
@@ -30,7 +30,6 @@
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"compression": "^1.8.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
@@ -39,8 +38,9 @@
"helmet": "^8.1.0",
"jimp": "^1.6.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
"nodemailer": "^9.0.1",
"nodemailer": "^8.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
@@ -60,7 +60,7 @@
"@hono/node-server": "^1.19.13",
"picomatch": "^4.0.4",
"ip-address": "^10.1.1",
"multer": "^2.2.0",
"multer": "^2.1.1",
"ws": "^8.21.0",
"qs": "^6.15.2",
"file-type": "^21.3.4"
@@ -73,7 +73,6 @@
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/compression": "^1.8.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.25",
@@ -81,7 +80,7 @@
"@types/multer": "^2.1.0",
"@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^8.0.1",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5",
"@types/semver": "^7.7.1",
"@types/supertest": "^6.0.3",
+8 -27
View File
@@ -151,37 +151,18 @@ function normalizeAdm0Feature(f) {
function normalizeAdm1(geo, a3, countryName) {
if (!geo?.features) return []
const a2 = A3_TO_A2[a3] || null
// Ensure every region in a country ends up with a distinct iso_3166_2 — the Atlas
// marks/unmarks regions by this code, so duplicates make one mark light up the whole
// country.
const used = new Set()
const uniq = (base) => {
let code = base, n = 2
while (used.has(code)) code = `${base}-${n++}`
used.add(code)
return code
}
return geo.features.map(f => {
const name = f.properties?.shapeName || ''
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS)
if (!geometry) return null
// shapeISO is a real ISO 3166-2 code for most features, but geoBoundaries sometimes
// fills it with the bare country code instead of a subdivision code — e.g. every
// Spanish region gets "ESP", every Chinese "CHN" (also CL/OM). Keep it only when it
// is a real `XX-…` subdivision code and not already taken; otherwise synthesize a
// stable, unique-per-country id from the region name so each region is independently
// markable.
const raw = f.properties?.shapeISO || ''
let code
if (/^[A-Za-z]{2}-[A-Za-z0-9]+$/.test(raw) && !used.has(raw)) {
code = raw
used.add(code)
} else if (a2) {
code = uniq(`${a2}-${name.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || 'RGN'}`)
} else {
code = raw
}
const a2 = A3_TO_A2[a3] || null
// shapeISO is a real ISO 3166-2 code for ~90% of features; geoBoundaries leaves the
// rest blank or uses an `XX_YYY` placeholder. Keep real/placeholder codes as-is
// (stable per polygon → manual mark/unmark works, real ones match Nominatim). For
// blank codes, synthesize a stable id mirroring the server's geocode fallback so
// every region is still markable.
let code = f.properties?.shapeISO || ''
if (!code && a2) code = `${a2}-${name.replace(/[^A-Za-z0-9]/g, '').substring(0, 3).toUpperCase()}`
return {
type: 'Feature',
// Property names the Atlas region layer + server getRegionGeo already read.
+36 -56
View File
@@ -1,11 +1,39 @@
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
const dataDir = path.resolve(__dirname, '../data');
// JWT_SECRET is always managed by the server — auto-generated on first start and
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
// via environment variable (env var would override a rotation on next restart).
const jwtSecretFile = path.join(dataDir, '.jwt_secret');
let _jwtSecret: string;
try {
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
} catch {
_jwtSecret = crypto.randomBytes(32).toString('hex');
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
console.log('Generated and saved JWT secret to', jwtSecretFile);
} catch (writeErr: unknown) {
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
console.warn('Sessions will reset on server restart.');
}
}
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
export let JWT_SECRET = _jwtSecret;
// Called by the admin rotate-jwt-secret endpoint to update the in-process
// binding that all middleware and route files reference.
export function updateJwtSecret(newSecret: string): void {
JWT_SECRET = newSecret;
}
// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets
// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.).
@@ -65,55 +93,18 @@ if (_encryptionKey) {
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
console.log('Encryption key persisted to', encKeyFile);
} catch (writeErr: unknown) {
console.warn(
'WARNING: Could not persist encryption key to disk:',
writeErr instanceof Error ? writeErr.message : writeErr,
);
console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.');
}
}
export const ENCRYPTION_KEY = _encryptionKey;
// JWT_SECRET is always managed by the server — auto-generated on first start and
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
// via environment variable (env var would override a rotation on next restart).
let _jwtSecret: string;
try {
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
} catch {
_jwtSecret = crypto.randomBytes(32).toString('hex');
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
console.log('Generated and saved JWT secret to', jwtSecretFile);
} catch (writeErr: unknown) {
console.warn(
'WARNING: Could not persist JWT secret to disk:',
writeErr instanceof Error ? writeErr.message : writeErr,
);
console.warn('Sessions will reset on server restart.');
}
}
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
export let JWT_SECRET = _jwtSecret;
// Called by the admin rotate-jwt-secret endpoint to update the in-process
// binding that all middleware and route files reference.
export function updateJwtSecret(newSecret: string): void {
JWT_SECRET = newSecret;
}
// DEFAULT_LANGUAGE sets the language shown on the login page before the user
// selects one. Only applies when the user has no saved language preference.
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
console.warn(
`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`,
);
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
}
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
@@ -125,13 +116,7 @@ export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ?
// challenge token or MCP OAuth tokens — those keep their own TTL.
const DEFAULT_SESSION_DURATION = '24h';
const DURATION_UNITS_MS: Record<string, number> = {
ms: 1,
s: 1000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
y: 31_557_600_000,
ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000, y: 31_557_600_000,
};
function parseDurationMs(value: string): number | null {
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
@@ -143,9 +128,7 @@ function parseDurationMs(value: string): number | null {
const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
const parsedSessionMs = parseDurationMs(rawSessionDuration);
if (parsedSessionMs == null) {
console.warn(
`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`,
);
console.warn(`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`);
}
/** Human-readable session length actually in effect (for logs/diagnostics). */
export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration;
@@ -163,13 +146,10 @@ const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
const parsedRememberMs = parseDurationMs(rawRememberDuration);
if (parsedRememberMs == null) {
console.warn(
`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`,
);
console.warn(`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`);
}
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
export const SESSION_DURATION_REMEMBER =
parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
export const SESSION_DURATION_REMEMBER = parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
-9
View File
@@ -3045,15 +3045,6 @@ function runMigrations(db: Database.Database): void {
'CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)',
);
},
() => {
// Per-user opt-in for writing TREK edits back to AirTrail (#1240). Default
// off: AirTrail is the source of truth and TREK never writes unless asked.
try {
db.exec('ALTER TABLE users ADD COLUMN airtrail_write_enabled INTEGER DEFAULT 0');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
];
if (currentVersion < migrations.length) {
-5
View File
@@ -18,11 +18,6 @@ function seedAdminAccount(db: Database.Database): void {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
if (userCount > 0) return;
// Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this.
// Creating a first-run admin here would grab username 'admin' first and make the demo
// seeder fail on the UNIQUE(username) constraint, leaving the demo user uncreated.
if (process.env.DEMO_MODE?.toLowerCase() === 'true') return;
if (isOidcOnlyConfigured()) {
console.log('');
console.log('╔══════════════════════════════════════════════╗');
-11
View File
@@ -66,17 +66,6 @@ export function hasTripPermission(action: string, tripId: number | string, userI
return checkPermission(action, userRow?.role ?? 'user', tripOwnerId, userId, tripOwnerId !== userId);
}
/** True when the user has the global admin role (mirrors REST `user.role === 'admin'` gates). */
export function isAdminUser(userId: number): boolean {
const userRow = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
return userRow?.role === 'admin';
}
/** Error response for admin-only tools, reproducing the REST `{ error: 'Admin access required' }` string. */
export function adminRequired() {
return { content: [{ type: 'text' as const, text: 'Admin access required' }], isError: true };
}
export function ok(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
+1 -5
View File
@@ -136,11 +136,7 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
async ({ regionCode, regionName, countryCode }) => {
if (isDemoUser(userId)) return demoDenied();
markRegionVisited(userId, regionCode, regionName, countryCode);
const row = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
// Echo in the client-facing shape ({ code, name, ... }) rather than raw DB columns.
const region = row
? { code: row.region_code, name: row.region_name, country_code: row.country_code, manuallyMarked: true }
: undefined;
const region = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
return ok({ region });
}
);
+25 -160
View File
@@ -5,42 +5,18 @@ import { isDemoUser } from '../../services/authService';
import {
createBudgetItem, updateBudgetItem, deleteBudgetItem,
updateMembers as updateBudgetMembers,
toggleMemberPaid, getBudgetItem,
calculateSettlement, listSettlements, createSettlement, updateSettlement, deleteSettlement,
toggleMemberPaid,
} from '../../services/budgetService';
import { getRates } from '../../services/exchangeRateService';
import { getTripOwner, listMembers } from '../../services/tripService';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_READONLY,
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
} from './_shared';
import { canRead, canWrite } from '../scopes';
/** Reusable Zod shape for the per-payer amounts on a budget item. */
const payersSchema = z.array(z.object({
user_id: z.number().int().positive(),
amount: z.number().nonnegative(),
})).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.');
import { canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
/**
* Resolve the equal-split participants for a new budget item. When member_ids is
* omitted, default to the whole trip (owner + all members), deduped reproducing
* the client's own create flow (CostsPanel seeds participants from all members).
* An explicit empty array means "planning-only, no split" and is passed through.
*/
function resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined {
if (member_ids !== undefined) return member_ids;
const owner = getTripOwner(tripId);
if (!owner) return undefined;
const { members } = listMembers(tripId, owner.user_id);
return Array.from(new Set([owner.user_id, ...members.map(m => m.id)]));
}
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'budget');
const W = canWrite(scopes, 'budget');
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
@@ -49,26 +25,21 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (W) server.registerTool(
'create_budget_item',
{
description: 'Add a budget/expense item to a trip. The cost is split equally among member_ids (omit to split across all trip members, or pass [] for a planning-only entry with no split). Use `payers` to record who actually paid and how much. Ask the user which trip members share this expense and who paid — resolve user IDs with list_trip_members — rather than guessing.',
description: 'Add a budget/expense item to a trip.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
total_price: z.number().nonnegative(),
currency: z.string().max(10).nullable().optional().describe('ISO currency code (e.g. "EUR"); defaults to the trip currency'),
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense. Omit to split across all trip members (owner + members); pass [] for no split.'),
payers: payersSchema.optional().describe('Who paid how much, in the expense currency. When given, total_price is derived from the sum. Ask the user; do not guess.'),
expense_date: z.string().max(40).nullable().optional().describe('Date the expense occurred, YYYY-MM-DD'),
note: z.string().max(500).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category, total_price, currency, member_ids, payers, expense_date, note }) => {
async ({ tripId, name, category, total_price, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const members = resolveMemberIds(tripId, member_ids);
const item = createBudgetItem(tripId, { category, name, total_price, currency, member_ids: members, payers, expense_date, note });
const item = createBudgetItem(tripId, { category, name, total_price, note });
safeBroadcast(tripId, 'budget:created', { item });
return ok({ item });
}
@@ -100,26 +71,24 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (W) server.registerTool(
'update_budget_item',
{
description: 'Update an existing budget/expense item in a trip. You can also re-split it via member_ids and record who actually paid via payers (amounts in the expense currency). When changing who shares an expense or who paid, ask the user rather than guessing; resolve user IDs with list_trip_members.',
description: 'Update an existing budget/expense item in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(),
total_price: z.number().nonnegative().optional(),
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense; replaces the current split. Omit to leave unchanged, pass [] for no split.'),
payers: payersSchema.optional().describe('Replaces who paid how much, in the expense currency. Omit to leave unchanged. Ask the user; do not guess.'),
persons: z.number().int().positive().nullable().optional(),
days: z.number().int().positive().nullable().optional(),
note: z.string().max(500).nullable().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, name, category, total_price, member_ids, payers, persons, days, note }) => {
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, member_ids, payers, persons, days, note });
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
safeBroadcast(tripId, 'budget:updated', { item });
return ok({ item });
@@ -131,14 +100,14 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (W) server.registerTool(
'create_budget_item_with_members',
{
description: 'Create a budget/expense item and set the trip members splitting it in one atomic operation. If userIds is omitted, the cost is split across all trip members; pass an explicit list to split among a subset, or an empty array for a planning-only entry with no split. Ask the user which members share this expense rather than guessing; resolve user IDs with list_trip_members. Only use when the item does not yet exist — if it already exists, use set_budget_item_members directly.',
description: 'Create a budget/expense item and optionally set the trip members splitting it in one atomic operation. If userIds is omitted or empty, behaves like create_budget_item. Only use when the place does not yet exist — if it already exists, use set_budget_item_members directly.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
total_price: z.number().nonnegative(),
note: z.string().max(500).optional(),
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit to split across all trip members, or pass an empty array for no split'),
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit or pass empty array to skip member assignment'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
@@ -146,16 +115,19 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
// Omitted userIds → default to the whole trip, matching create_budget_item.
const members = (userIds && userIds.length > 0) ? userIds : resolveMemberIds(tripId, undefined);
const hasMembers = userIds && userIds.length > 0;
try {
const item = db.transaction(() => {
const created = createBudgetItem(tripId, { category, name, total_price, note, member_ids: members });
return getBudgetItem(created.id, tripId)!;
})();
safeBroadcast(tripId, 'budget:created', { item });
if (members && members.length > 0) safeBroadcast(tripId, 'budget:members-updated', { item });
return ok({ item });
const run = db.transaction(() => {
const item = createBudgetItem(tripId, { category, name, total_price, note });
if (hasMembers) {
return updateBudgetMembers(item.id, tripId, userIds!);
}
return { item };
});
const result = run();
safeBroadcast(tripId, 'budget:created', { item: (result as any).item ?? result });
if (hasMembers) safeBroadcast(tripId, 'budget:members-updated', { item: result });
return ok({ item: result });
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
}
@@ -165,7 +137,7 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (W) server.registerTool(
'set_budget_item_members',
{
description: 'Set which trip members are splitting a budget item (replaces current member list). Ask the user which members share the expense; resolve user IDs with list_trip_members.',
description: 'Set which trip members are splitting a budget item (replaces current member list).',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
@@ -177,9 +149,7 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const result = updateBudgetMembers(itemId, tripId, userIds);
if (!result) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
const item = getBudgetItem(itemId, tripId);
const item = updateBudgetMembers(itemId, tripId, userIds);
safeBroadcast(tripId, 'budget:members-updated', { item });
return ok({ item });
}
@@ -206,110 +176,5 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
return ok({ member });
}
);
// --- SETTLEMENTS (settle-up payments between members) ---
if (R) server.registerTool(
'get_settlement_summary',
{
description: "See each member's net balance and the suggested payments to settle shared expenses. Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.",
inputSchema: {
tripId: z.number().int().positive(),
base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId, base }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?').get(tripId) as { currency?: string } | undefined;
const tripCurrency = trip?.currency || 'EUR';
const effectiveBase = (base || tripCurrency).toUpperCase();
const rates = await getRates(effectiveBase);
const summary = calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
return ok({ summary });
}
);
if (R) server.registerTool(
'list_settlements',
{
description: 'List the recorded settle-up payments for a trip (who paid whom, how much, when).',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
return ok({ settlements: listSettlements(tripId) });
}
);
if (W) server.registerTool(
'create_settlement',
{
description: "Record a settle-up payment: from_user_id paid to_user_id the given amount (in the trip's base currency) to settle shared expenses. Use get_settlement_summary first to find who owes whom and how much.",
inputSchema: {
tripId: z.number().int().positive(),
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, from_user_id, to_user_id, amount }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const settlement = createSettlement(tripId, { from_user_id, to_user_id, amount }, userId);
safeBroadcast(tripId, 'budget:settlement-created', { settlement });
return ok({ settlement });
}
);
if (W) server.registerTool(
'update_settlement',
{
description: 'Update a recorded settle-up payment (who paid, who received, and the amount).',
inputSchema: {
tripId: z.number().int().positive(),
settlementId: z.number().int().positive(),
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, settlementId, from_user_id, to_user_id, amount }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const settlement = updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount });
if (!settlement) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
return ok({ settlement });
}
);
if (W) server.registerTool(
'delete_settlement',
{
description: 'Delete a recorded settle-up payment. This is the undo for create_settlement and restores the affected balances.',
inputSchema: {
tripId: z.number().int().positive(),
settlementId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, settlementId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const deleted = deleteSettlement(settlementId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
safeBroadcast(tripId, 'budget:settlement-deleted', { settlementId });
return ok({ success: true });
}
);
} // isAddonEnabled(BUDGET)
}
+8 -11
View File
@@ -99,20 +99,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
start_day_id: z.number().int().positive().describe('Check-in day ID'),
end_day_id: z.number().int().positive().describe('Check-out day ID'),
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
confirmation: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => {
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
safeBroadcast(tripId, 'accommodation:created', { accommodation });
return ok({ accommodation });
}
@@ -138,7 +137,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
start_day_id: z.number().int().positive().describe('Check-in day ID'),
end_day_id: z.number().int().positive().describe('Check-out day ID'),
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
confirmation: z.string().max(100).optional(),
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
@@ -147,7 +145,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => {
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
@@ -156,7 +154,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
try {
const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
return { place, accommodation };
});
const result = run();
@@ -180,20 +178,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
start_day_id: z.number().int().positive().optional(),
end_day_id: z.number().int().positive().optional(),
check_in: z.string().max(10).optional(),
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
check_out: z.string().max(10).optional(),
confirmation: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => {
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const existing = getAccommodation(accommodationId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
safeBroadcast(tripId, 'accommodation:updated', { accommodation });
return ok({ accommodation });
}
@@ -230,7 +227,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
text: z.string().min(1).max(500),
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
icon: z.string().optional().describe('Emoji icon for the note'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -255,7 +252,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
dayId: z.number().int().positive(),
noteId: z.number().int().positive(),
text: z.string().min(1).max(500).optional(),
time: z.string().max(250).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
icon: z.string().optional().describe('Emoji icon for the note'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
+4 -11
View File
@@ -136,9 +136,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
async ({ title, subtitle, trip_ids }) => {
if (isDemoUser(userId)) return demoDenied();
const journey = createJourney(userId, { title, subtitle, trip_ids });
// Return the fully-hydrated journey (entries/contributors/trips/stats/my_role),
// matching get_journey, rather than the bare row.
return ok({ journey: getJourneyFull(journey.id, userId) ?? journey });
return ok({ journey });
}
);
@@ -235,9 +233,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
if (!entry) return notFound('Journey not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos, source_trip_name).
const enriched = listEntries(journeyId, userId)?.find(e => e.id === entry.id) ?? entry;
return ok({ entry: enriched });
return ok({ entry });
}
);
@@ -259,9 +255,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
if (!entry) return notFound('Entry not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos), matching create_journey_entry.
const enriched = listEntries(entry.journey_id, userId)?.find(e => e.id === entry.id) ?? entry;
return ok({ entry: enriched });
return ok({ entry });
}
);
@@ -370,8 +364,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
if (!result) return notFound('Journey not found or access denied.');
// Return the service result ({ hide_skeletons }), matching the REST route.
return ok(result);
return ok({ success: true });
}
);
+21 -68
View File
@@ -9,16 +9,15 @@ import {
listBags, createBag, updateBag, deleteBag, setBagMembers,
getCategoryAssignees as getPackingCategoryAssignees,
updateCategoryAssignees as updatePackingCategoryAssignees,
applyTemplate, saveAsTemplate, listTemplates, bulkImport,
applyTemplate, saveAsTemplate, bulkImport,
} from '../../services/packingService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
isAdminUser, adminRequired,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import { isAddonEnabled, deletePackingTemplate } from '../../services/adminService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
export function registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
@@ -172,9 +171,7 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
// createBag returns a bare row; hydrate with the empty members array that
// listBags and the schema always carry, so the client/AI consumer matches.
const bag = { ...(createBag(tripId, { name, color }) as object), members: [] };
const bag = createBag(tripId, { name, color });
safeBroadcast(tripId, 'packing:bag-created', { bag });
return ok({ bag });
}
@@ -200,10 +197,7 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
const bodyKeys: string[] = [];
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
const updated = updateBag(tripId, bagId, fields, bodyKeys);
if (!updated) return { content: [{ type: 'text' as const, text: 'Bag not found.' }], isError: true };
// Hydrate with the members array (matches create_packing_bag, listBags, and the schema).
const bag = listBags(tripId).find(b => b.id === (updated as { id: number }).id) ?? { ...(updated as object), members: [] };
const bag = updateBag(tripId, bagId, fields, bodyKeys);
safeBroadcast(tripId, 'packing:bag-updated', { bag });
return ok({ bag });
}
@@ -244,10 +238,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const members = setBagMembers(tripId, bagId, userIds);
if (!members) return { content: [{ type: 'text' as const, text: 'Bag not found.' }], isError: true };
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members });
return ok({ members });
setBagMembers(tripId, bagId, userIds);
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
return ok({ success: true });
}
);
@@ -282,9 +275,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const assignees = updatePackingCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees });
return ok({ assignees });
updatePackingCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
return ok({ success: true });
}
);
@@ -302,32 +295,17 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const items = applyTemplate(tripId, templateId);
if (items === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
safeBroadcast(tripId, 'packing:template-applied', { items });
return ok({ items, count: items.length });
}
);
if (R) server.registerTool(
'list_packing_templates',
{
description: 'List the reusable packing templates (id, name, item count) so one can be applied with apply_packing_template.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
return ok({ templates: listTemplates() });
const applied = applyTemplate(tripId, templateId);
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
safeBroadcast(tripId, 'packing:template-applied', { templateId });
return ok({ success: true });
}
);
if (W) server.registerTool(
'save_packing_template',
{
description: 'Save the current packing list as a reusable template. Returns the new template (id, name, category/item counts). Admin only.',
description: 'Save the current packing list as a reusable template.',
inputSchema: {
tripId: z.number().int().positive(),
templateName: z.string().min(1).max(100),
@@ -338,46 +316,21 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
// Templates are global; the REST route restricts saving to admins. Match it.
if (!isAdminUser(userId)) return adminRequired();
const template = saveAsTemplate(tripId, userId, templateName);
if (!template) return { content: [{ type: 'text' as const, text: 'Nothing to save — the packing list is empty.' }], isError: true };
return ok({ template });
}
);
if (W) server.registerTool(
'delete_packing_template',
{
description: 'Delete a reusable packing template. Templates are global, so deletion is admin only.',
inputSchema: {
templateId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ templateId }) => {
if (isDemoUser(userId)) return demoDenied();
// Templates are global; the REST route restricts management to admins. Match it.
if (!isAdminUser(userId)) return adminRequired();
const result = deletePackingTemplate(String(templateId));
if ('error' in result) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true, name: result.name });
saveAsTemplate(tripId, userId, templateName);
return ok({ success: true });
}
);
if (W) server.registerTool(
'bulk_import_packing',
{
description: 'Import multiple packing items at once from a list. Optionally assign each to a bag (by name — created if missing), set its weight, or pre-check it.',
description: 'Import multiple packing items at once from a list.',
inputSchema: {
tripId: z.number().int().positive(),
items: z.array(z.object({
name: z.string().min(1).max(200),
category: z.string().optional(),
quantity: z.number().int().positive().optional(),
bag: z.string().max(100).optional().describe('Bag name to assign the item to; created if it does not exist'),
weight_grams: z.number().nonnegative().optional(),
checked: z.boolean().optional(),
})).min(1),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -386,9 +339,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const created = bulkImport(tripId, items);
for (const item of created) safeBroadcast(tripId, 'packing:created', { item });
return ok({ items: created, count: created.length });
bulkImport(tripId, items);
safeBroadcast(tripId, 'packing:updated', {});
return ok({ success: true, count: items.length });
}
);
}
+1 -1
View File
@@ -221,7 +221,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
safeBroadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation, accommodation_id: (reservation as any)?.accommodation_id ?? null });
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
}
);
}
+6 -58
View File
@@ -4,11 +4,9 @@ import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
createReservation, deleteReservation, getReservation, updateReservation,
type EndpointInput,
} from '../../services/reservationService';
import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService';
import { findByIata } from '../../services/airportService';
import {
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
@@ -17,56 +15,17 @@ import { canWrite } from '../scopes';
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
const endpointObjectSchema = z.object({
const endpointSchema = z.array(z.object({
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
lat: z.number().optional().describe('Latitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'),
lng: z.number().optional().describe('Longitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'),
lat: z.number().optional(),
lng: z.number().optional(),
timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'),
local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'),
local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'),
});
const endpointSchema = z.array(endpointObjectSchema).optional();
type Endpoint = z.infer<typeof endpointObjectSchema>;
/**
* Endpoint coordinates are stored NOT NULL. Callers may supply a flight endpoint
* with only an IATA `code` (the tool description encourages this), so fill missing
* lat/lng/timezone from the airport database. Returns an error string for the first
* endpoint that can't be resolved rather than letting the NOT NULL bind throw.
*
* Normalizes to the service's EndpointInput shape (nullable fields coerced from the
* schema's optionals), so lat/lng are guaranteed present before the insert.
*/
function resolveEndpointCoords(endpoints: Endpoint[] | undefined): { endpoints: EndpointInput[] } | { error: string } {
if (!endpoints) return { endpoints: [] };
const out: EndpointInput[] = [];
for (const e of endpoints) {
const base = {
role: e.role,
sequence: e.sequence,
name: e.name,
code: e.code ?? null,
timezone: e.timezone ?? null,
local_time: e.local_time ?? null,
local_date: e.local_date ?? null,
};
if (e.lat != null && e.lng != null) { out.push({ ...base, lat: e.lat, lng: e.lng }); continue; }
if (e.code) {
const airport = findByIata(e.code);
if (airport) {
out.push({ ...base, lat: airport.lat, lng: airport.lng, timezone: e.timezone ?? airport.tz });
continue;
}
return { error: `Could not resolve airport code "${e.code}". Use search_airports to find a valid IATA code, or supply lat/lng directly.` };
}
return { error: `Endpoint "${e.name}" is missing coordinates. For flights set "code" to the IATA airport code; for other transport types supply lat/lng.` };
}
return { endpoints: out };
}
})).optional();
export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!canWrite(scopes, 'reservations')) return;
@@ -104,9 +63,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
if (end_day_id && !getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
const resolved = resolveEndpointCoords(endpoints);
if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true };
const meta: Record<string, string> = { ...(metadata ?? {}) };
if (price != null) meta.price = String(price);
@@ -122,7 +78,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
end_day_id: end_day_id ?? start_day_id,
status: status ?? 'pending',
metadata: Object.keys(meta).length > 0 ? meta : undefined,
endpoints: resolved.endpoints,
endpoints,
needs_review,
});
@@ -179,14 +135,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
if (end_day_id && !getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
// Only resolve when endpoints are explicitly provided; undefined leaves them untouched.
let resolvedEndpoints: EndpointInput[] | undefined;
if (endpoints !== undefined) {
const resolved = resolveEndpointCoords(endpoints);
if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true };
resolvedEndpoints = resolved.endpoints;
}
const { reservation } = updateReservation(reservationId, tripId, {
title,
type,
@@ -198,7 +146,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
end_day_id,
status,
metadata,
endpoints: resolvedEndpoints,
endpoints,
needs_review,
}, existing);
safeBroadcast(tripId, 'reservation:updated', { reservation });
+3 -6
View File
@@ -55,10 +55,8 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
// updatePlan already returns the fully-hydrated { plan }; surface it so the
// AI consumer sees the updated plan, matching get_vacay_plan.
const result = await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
return ok(result);
await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
return ok({ success: true });
}
);
@@ -75,8 +73,7 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
setUserColor(userId, planId, color, undefined);
// Echo the persisted color (mirrors the service default) so the AI consumer sees what was set.
return ok({ success: true, color: color || '#6366f1' });
return ok({ success: true });
}
);
+1 -19
View File
@@ -1,5 +1,4 @@
import express, { Request, Response, NextFunction } from 'express';
import compression from 'compression';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
@@ -29,21 +28,6 @@ export function applyGlobalMiddleware(
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
}
// Compress responses (gzip via Accept-Encoding). The Atlas admin-0 country
// GeoJSON is ~30 MB uncompressed, which stalls/aborts (~8s → net::ERR_FAILED)
// behind reverse proxies and Cloudflare Tunnel (#1254); gzip brings it to ~4 MB.
// SSE responses (the /mcp StreamableHTTP transport) must NOT be buffered, so
// they are excluded explicitly.
app.use(
compression({
filter: (req, res) => {
const type = res.getHeader('Content-Type');
if (typeof type === 'string' && type.includes('text/event-stream')) return false;
return compression.filter(req, res);
},
}),
);
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
: null;
@@ -119,9 +103,7 @@ export function applyGlobalMiddleware(
workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
objectSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
frameAncestors: ["'self'"],
// Restrict <form> submission targets (form-action has no default-src
+1 -26
View File
@@ -94,31 +94,6 @@ export class BudgetController {
return { settlement };
}
@Put('settlements/:settlementId')
updateSettlement(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('settlementId') settlementId: string,
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number },
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (body.from_user_id == null || body.to_user_id == null || body.amount == null) {
throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400);
}
const settlement = this.budget.updateSettlement(settlementId, tripId, {
from_user_id: body.from_user_id,
to_user_id: body.to_user_id,
amount: body.amount,
});
if (!settlement) {
throw new HttpException({ error: 'Settlement not found' }, 404);
}
this.budget.broadcast(tripId, 'budget:settlement-updated', { settlement }, socketId);
return { settlement };
}
@Delete('settlements/:settlementId')
deleteSettlement(
@CurrentUser() user: User,
@@ -139,7 +114,7 @@ export class BudgetController {
create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; reservation_id?: number },
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
-4
View File
@@ -73,10 +73,6 @@ export class BudgetService {
return svc.createSettlement(tripId, data, userId);
}
updateSettlement(id: string, tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }) {
return svc.updateSettlement(id, tripId, data);
}
deleteSettlement(id: string, tripId: string): boolean {
return svc.deleteSettlement(id, tripId);
}
+3 -4
View File
@@ -17,10 +17,9 @@ import { CurrentUser } from '../auth/current-user.decorator';
type DayNoteBody = { text?: string; time?: string; icon?: string; sort_order?: number };
// Runs BEFORE the trip-access check, so an over-long field 400s first. The `time`
// cap matches the shared dayNote schema (max 250) and the note dialog's counter;
// it was 150 here, which rejected valid 151250 char notes with a confusing error.
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 250 };
// Mirrors the legacy validateStringLengths({ text: 500, time: 150 }) middleware,
// which runs BEFORE the trip-access check — so an over-long field 400s first.
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 150 };
function validateLengths(body: Record<string, unknown>): void {
for (const [field, max] of Object.entries(MAX_LENGTHS)) {
@@ -43,7 +43,6 @@ export class AirtrailController {
body.url,
body.apiKey,
!!body.allowInsecureTls,
!!body.writeEnabled,
getClientIp(req),
);
if (!result.success) {
@@ -5,7 +5,6 @@ import { checkPermission } from '../../services/permissions';
import type { User } from '../../types';
import * as svc from '../../services/reservationService';
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService';
import { typeToCostCategory } from '@trek/shared';
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
type BudgetEntry = { total_price?: number; category?: string } | undefined;
@@ -78,51 +77,30 @@ export class ReservationsService {
/** PUT side effect: drop the linked budget item when the price is cleared, else create/update it. */
syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
// When the booking type changes, keep a linked expense's category in sync —
// but only if it still carries the auto-derived category (so a manual pick in
// the Costs editor is preserved). Runs regardless of create_budget_entry.
if (type && currentType && type !== currentType) {
const linked = db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined;
if (linked) {
const oldCat = typeToCostCategory(currentType);
const newCat = typeToCostCategory(type);
if (oldCat !== newCat && linked.category === oldCat) {
const updated = updateBudgetItem(linked.id, tripId, { category: newCat });
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
}
}
}
// No budget entry on the payload — the booking edit isn't touching its linked
// expense, so leave any linked item alone. Expenses are managed from the
// booking's Costs section / the Costs tab, not by re-saving the booking.
if (!entry) return;
if (!(Number(entry.total_price) > 0)) {
// Explicit clear (total_price 0/empty) — drop the linked item.
if (!entry || !entry.total_price) {
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (linked) {
deleteBudgetItem(linked.id, tripId);
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId);
}
return;
}
try {
const itemName = title || currentTitle;
const category = entry.category || type || currentType || 'Other';
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (existing) {
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
} else {
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
item.reservation_id = Number(id);
broadcast(tripId, 'budget:created', { item }, socketId);
if (entry && Number(entry.total_price) > 0) {
try {
const itemName = title || currentTitle;
const category = entry.category || type || currentType || 'Other';
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (existing) {
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
} else {
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
item.reservation_id = Number(id);
broadcast(tripId, 'budget:created', { item }, socketId);
}
} catch (err) {
console.error('[reservations] Failed to create/update budget entry:', err);
}
} catch (err) {
console.error('[reservations] Failed to create/update budget entry:', err);
}
}
-10
View File
@@ -141,16 +141,6 @@ export function updateUser(id: string, data: { username?: string; email?: string
}
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
// Don't let the admin UI demote the last remaining admin — that would leave the
// instance with no one able to manage it (and on OIDC-only setups, no recovery). #1274
if (role && role !== 'admin') {
const current = db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role?: string } | undefined;
if (current?.role === 'admin') {
const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count;
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
}
}
db.prepare(`
UPDATE users SET
username = COALESCE(?, username),
@@ -78,8 +78,6 @@ export interface AirtrailFlightRaw {
datePrecision: string | null;
departure: string | null;
arrival: string | null;
departureScheduled: string | null;
arrivalScheduled: string | null;
airline: AirtrailNamedCode | null;
flightNumber: string | null;
aircraft: AirtrailNamedCode | null;
@@ -94,14 +92,10 @@ export interface AirtrailSavePayload {
id?: number;
from: string;
to: string;
departure: string | null;
departure: string;
departureTime?: string | null;
arrival?: string | null;
arrivalTime?: string | null;
departureScheduled?: string | null;
departureScheduledTime?: string | null;
arrivalScheduled?: string | null;
arrivalScheduledTime?: string | null;
datePrecision?: string;
airline?: string | null;
flightNumber?: string | null;
+10 -13
View File
@@ -11,7 +11,7 @@ function airportCode(a: AirtrailAirport | null): string | null {
* Airline/aircraft arrive as joined objects ({icao, iata, name, ...}); reduce
* them to a single code (ICAO preferred, matching AirTrail's save shape).
*/
export function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
return e?.icao || e?.iata || null;
}
@@ -55,8 +55,8 @@ export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
toCode: airportCode(raw.to),
toName: raw.to?.name ?? null,
date: raw.date ?? null,
departure: raw.departureScheduled ?? null,
arrival: raw.arrivalScheduled ?? null,
departure: raw.departure ?? null,
arrival: raw.arrival ?? null,
airline: entityCode(raw.airline),
flightNumber: raw.flightNumber ?? null,
aircraft: entityCode(raw.aircraft),
@@ -94,17 +94,14 @@ function hasCoords(a: AirtrailAirport | null): a is AirtrailAirport & { lat: num
/** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */
export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation {
// Read the SCHEDULED times only — TREK plans against the scheduled (booked) time,
// not the actual/estimated `departure`/`arrival`. When a flight has no scheduled
// time, the clock is left blank (date preserved) rather than fabricated.
const dep = localParts(raw.departureScheduled, raw.from?.tz ?? null);
const arr = localParts(raw.arrivalScheduled, raw.to?.tz ?? null);
const dep = localParts(raw.departure, raw.from?.tz ?? null);
const arr = localParts(raw.arrival, raw.to?.tz ?? null);
const fromCode = airportCode(raw.from);
const toCode = airportCode(raw.to);
const datePrefix = raw.date || dep.date;
const reservation_time = dep.date && dep.time ? `${dep.date}T${dep.time}` : (datePrefix ?? null);
const reservation_end_time = arr.date && arr.time ? `${arr.date}T${arr.time}` : null;
const reservation_time = datePrefix ? `${datePrefix}T${dep.time ?? '00:00'}` : null;
const reservation_end_time = arr.date ? `${arr.date}T${arr.time ?? '00:00'}` : null;
const endpoints: MappedEndpoint[] = [];
let needsReview = raw.datePrecision && raw.datePrecision !== 'day' ? 1 : 0;
@@ -150,7 +147,7 @@ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservatio
if (aircraftCode) metadata.aircraft = aircraftCode;
if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg;
if (raw.flightReason) metadata.flight_reason = raw.flightReason;
if (seat?.seatNumber) metadata.seat = seat.seatNumber;
if (seat?.seatNumber || seat?.seatClass) metadata.seat = seat.seatNumber || seat.seatClass;
// The flight number already carries the airline prefix (e.g. "SAS983"), so it
// makes the clearest title; fall back to the route.
@@ -181,8 +178,8 @@ export function canonicalHash(raw: AirtrailFlightRaw): string {
to: airportCode(raw.to),
date: raw.date ?? null,
datePrecision: raw.datePrecision ?? 'day',
departureScheduled: raw.departureScheduled ?? null,
arrivalScheduled: raw.arrivalScheduled ?? null,
departure: raw.departure ?? null,
arrival: raw.arrival ?? null,
airline: entityCode(raw.airline),
flightNumber: raw.flightNumber ?? null,
aircraft: entityCode(raw.aircraft),
@@ -12,25 +12,14 @@ interface UserConnRow {
airtrail_url?: string | null;
airtrail_api_key?: string | null;
airtrail_allow_insecure_tls?: number | null;
airtrail_write_enabled?: number | null;
}
function readRow(userId: number): UserConnRow | undefined {
return db
.prepare(
'SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls, airtrail_write_enabled FROM users WHERE id = ?',
)
.prepare('SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls FROM users WHERE id = ?')
.get(userId) as UserConnRow | undefined;
}
/** Has this user opted in to TREK writing their flight edits back to AirTrail? (#1240) */
export function isAirtrailWriteEnabled(userId: number): boolean {
const row = db.prepare('SELECT airtrail_write_enabled FROM users WHERE id = ?').get(userId) as
| { airtrail_write_enabled?: number | null }
| undefined;
return !!row?.airtrail_write_enabled;
}
/** Decrypted creds for outbound calls, or null when the user has no connection. */
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
const row = readRow(userId);
@@ -51,7 +40,6 @@ export function getConnectionSettings(userId: number) {
url: row?.airtrail_url || '',
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
writeEnabled: !!row?.airtrail_write_enabled,
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
};
}
@@ -61,7 +49,6 @@ export async function saveSettings(
url: string | undefined,
apiKey: string | undefined,
allowInsecureTls: boolean,
writeEnabled: boolean,
clientIp: string | null,
): Promise<{ success: boolean; warning?: string; error?: string }> {
const trimmedUrl = (url || '').trim();
@@ -94,12 +81,12 @@ export async function saveSettings(
if (newKey !== undefined) {
db.prepare(
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0, userId);
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId);
} else {
db.prepare(
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0, userId);
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId);
// Clearing the URL with no key left makes the connection meaningless — drop the key too.
if (!trimmedUrl) {
db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
+13 -43
View File
@@ -13,8 +13,8 @@ import {
listFlights,
saveFlight,
} from './airtrailClient';
import { canonicalHash, entityCode, mapFlightToReservation } from './airtrailMapper';
import { getAirtrailCredentials, isAirtrailWriteEnabled } from './airtrailService';
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
import { getAirtrailCredentials } from './airtrailService';
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
export function syncGloballyEnabled(): boolean {
@@ -144,16 +144,7 @@ function splitLocal(dt: string | null | undefined): { date: string | null; time:
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time: m ? m[1] : null };
}
/**
* Build the POST /flight/save body. AirTrail's save fully overwrites the flight,
* so we start from the flight as AirTrail currently has it (`existing`, the raw
* GET object) and overwrite ONLY the fields TREK manages. Everything else
* terminal, gate, scheduled/actual times, customFields, track, and any field
* AirTrail may add later passes through untouched. We deliberately do NOT model
* those fields; spreading the raw object keeps us decoupled from AirTrail's schema
* (#1240).
*/
export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
let meta: Record<string, any>;
try {
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
@@ -192,14 +183,7 @@ export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw):
if (ownSeat) ownSeat.seatNumber = seatNumber;
}
// Spread the existing flight first to preserve every AirTrail-owned field, then
// overwrite only what TREK manages. `from`/`to`/`airline`/`aircraft` come back
// from GET as objects but the save shape wants codes — those are exactly the
// keys we override, so the spread never ships an object where a code is wanted.
return {
// Cast so the spread carries through the AirTrail-owned keys we deliberately
// don't model (terminal, gate, scheduled/actual times, customFields, track, …).
...(existing as unknown as Record<string, unknown>),
id: Number(reservation.external_id),
from: fromCode,
to: toCode,
@@ -207,25 +191,14 @@ export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw):
departureTime: dep.time,
arrival: arr.date,
arrivalTime: arr.time,
// Import reads the SCHEDULED time, so a TREK edit must write back there too —
// otherwise the next pull (scheduled-wins) would revert it. AirTrail rebuilds the
// instant from a full-ISO date carrier + the HH:MM time, so pass a date carrier.
departureScheduled: dep.date ? `${dep.date}T00:00:00.000Z` : null,
departureScheduledTime: dep.time,
arrivalScheduled: arr.date ? `${arr.date}T00:00:00.000Z` : null,
arrivalScheduledTime: arr.time,
// These are AirTrail-owned details TREK doesn't surface in its edit UI — a TREK
// edit can leave them out of `metadata`. Preserve AirTrail's current value when
// TREK has none rather than nulling it out (#1240). entityCode mirrors the
// import/hash code-selection so a writeback stays a no-op for the hash.
airline: meta.airline ?? entityCode(existing.airline) ?? null,
flightNumber: meta.flight_number ?? existing.flightNumber ?? null,
aircraft: meta.aircraft ?? entityCode(existing.aircraft) ?? null,
aircraftReg: meta.aircraft_reg ?? existing.aircraftReg ?? null,
flightReason: meta.flight_reason ?? existing.flightReason ?? null,
note: reservation.notes ?? existing.note ?? null,
airline: meta.airline ?? null,
flightNumber: meta.flight_number ?? null,
aircraft: meta.aircraft ?? null,
aircraftReg: meta.aircraft_reg ?? null,
flightReason: meta.flight_reason ?? null,
note: reservation.notes ?? null,
seats,
} as AirtrailSavePayload;
};
}
/**
@@ -246,12 +219,9 @@ export async function pushReservationToAirtrail(reservationId: number, tripId: n
| undefined;
if (!row || !row.sync_enabled) return;
// AirTrail is read-only by default (#1240). Only push when the flight's owner has
// explicitly opted in. A no-op skip (not a detach): the link stays active so the
// inbound, AirTrail-wins pull keeps the reservation up to date.
if (!row.external_owner_user_id || !isAirtrailWriteEnabled(row.external_owner_user_id)) return;
const creds: AirtrailCreds | null = getAirtrailCredentials(row.external_owner_user_id);
const creds: AirtrailCreds | null = row.external_owner_user_id
? getAirtrailCredentials(row.external_owner_user_id)
: null;
if (!creds) {
detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
return;
+24 -1
View File
@@ -3,7 +3,6 @@ import path from 'path';
import zlib from 'zlib';
import { db } from '../db/database';
import { Trip, Place } from '../types';
import { CONTINENT_MAP } from '@trek/shared';
// ── Bundled boundary GeoJSON (admin-0 countries + admin-1 regions) ─────────
//
@@ -169,6 +168,30 @@ export const NAME_TO_CODE: Record<string, string> = {
'liechtenstein':'LI','gibraltar':'GI','puerto rico':'PR',
};
export const CONTINENT_MAP: Record<string, string> = {
AF:'Asia',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
BA:'Europe',BD:'Asia',BF:'Africa',BH:'Asia',BI:'Africa',BJ:'Africa',BN:'Asia',BO:'South America',
BR:'South America',BE:'Europe',BG:'Europe',BW:'Africa',
CA:'North America',CD:'Africa',CG:'Africa',CI:'Africa',CL:'South America',CM:'Africa',CN:'Asia',CO:'South America',
CR:'North America',CU:'North America',CV:'Africa',CY:'Europe',HR:'Europe',CZ:'Europe',
DJ:'Africa',DK:'Europe',DO:'North America',EC:'South America',EG:'Africa',EE:'Europe',ER:'Africa',ET:'Africa',
FI:'Europe',FR:'Europe',DE:'Europe',GE:'Asia',GH:'Africa',GN:'Africa',GR:'Europe',GT:'North America',
HN:'North America',HT:'North America',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',IR:'Asia',IQ:'Asia',
IE:'Europe',IL:'Asia',IT:'Europe',JM:'North America',JO:'Asia',JP:'Asia',KE:'Africa',KG:'Asia',KH:'Asia',
KR:'Asia',KW:'Asia',KZ:'Asia',LA:'Asia',LB:'Asia',LK:'Asia',LV:'Europe',LT:'Europe',LU:'Europe',LY:'Africa',
MA:'Africa',MD:'Europe',ME:'Europe',MG:'Africa',MK:'Europe',ML:'Africa',MM:'Asia',MN:'Asia',MR:'Africa',
MT:'Europe',MU:'Africa',MV:'Asia',MW:'Africa',MY:'Asia',MX:'North America',MZ:'Africa',
NA:'Africa',NE:'Africa',NI:'North America',NL:'Europe',NP:'Asia',NZ:'Oceania',NO:'Europe',OM:'Asia',
PA:'North America',PG:'Oceania',PK:'Asia',PE:'South America',PH:'Asia',PL:'Europe',PS:'Asia',
PT:'Europe',PY:'South America',QA:'Asia',RO:'Europe',RU:'Europe',RW:'Africa',SA:'Asia',SC:'Africa',
SD:'Africa',SG:'Asia',SI:'Europe',SK:'Europe',SN:'Africa',SO:'Africa',RS:'Europe',SV:'North America',
SY:'Asia',TG:'Africa',TJ:'Asia',TM:'Asia',TN:'Africa',TT:'North America',TW:'Asia',TZ:'Africa',
ZA:'Africa',SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',UG:'Africa',UY:'South America',
UZ:'Asia',VE:'South America',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',XK:'Europe',
YE:'Asia',ZM:'Africa',ZW:'Africa',NG:'Africa',
HK:'Asia',MO:'Asia',SM:'Europe',VA:'Europe',MC:'Europe',LI:'Europe',GI:'Europe',PR:'North America',
};
// ── Geocoding helpers ───────────────────────────────────────────────────────
let lastNominatimCall = 0;
+1 -4
View File
@@ -935,16 +935,13 @@ export function getTravelStats(userId: number) {
WHERE t.user_id = ? OR tm.user_id = ?
`).all(userId, userId) as { address: string | null; lat: number | null; lng: number | null }[];
// Archived trips still count here, matching the places, countries and flight
// distance widgets (which never filtered on is_archived) so the dashboard stats
// stay consistent — archiving a trip no longer zeroes out trips/days.
const tripStats = db.prepare(`
SELECT COUNT(DISTINCT t.id) as trips,
COUNT(DISTINCT d.id) as days
FROM trips t
LEFT JOIN days d ON d.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?)
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
`).get(userId, userId) as { trips: number; days: number } | undefined;
const cities = new Set<string>();
+1 -15
View File
@@ -15,21 +15,7 @@ const dataDir = path.join(__dirname, '../../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../../uploads');
// Compressed upload cap for restore archives. Defaults to 500 MB, raisable via
// BACKUP_UPLOAD_LIMIT_MB for instances whose backups (uploads/ included) grow
// past that. Invalid values warn and fall back to the default.
const DEFAULT_BACKUP_UPLOAD_LIMIT_MB = 500;
const rawBackupUploadLimit = process.env.BACKUP_UPLOAD_LIMIT_MB?.trim();
let backupUploadLimitMb = DEFAULT_BACKUP_UPLOAD_LIMIT_MB;
if (rawBackupUploadLimit) {
const parsed = Number(rawBackupUploadLimit);
if (Number.isFinite(parsed) && parsed > 0) {
backupUploadLimitMb = parsed;
} else {
console.warn(`BACKUP_UPLOAD_LIMIT_MB="${rawBackupUploadLimit}" is not a positive number. Falling back to ${DEFAULT_BACKUP_UPLOAD_LIMIT_MB} MB.`);
}
}
export const MAX_BACKUP_UPLOAD_SIZE = backupUploadLimitMb * 1024 * 1024; // compressed
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB compressed
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
// limit only caps the compressed bytes). Generous enough for any real backup.
export const MAX_BACKUP_DECOMPRESSED_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
+5 -44
View File
@@ -105,7 +105,6 @@ export function createBudgetItem(
currency?: string | null; exchange_rate?: number;
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
reservation_id?: number | null;
},
) {
const maxOrder = db.prepare(
@@ -129,7 +128,7 @@ export function createBudgetItem(
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
const result = db.prepare(
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
tripId,
cat,
@@ -142,7 +141,6 @@ export function createBudgetItem(
data.note || null,
sortOrder,
data.expense_date || null,
data.reservation_id != null ? data.reservation_id : null,
);
const itemId = result.lastInsertRowid as number;
@@ -158,15 +156,6 @@ export function createBudgetItem(
return item;
}
/** Fetch a single budget item hydrated with its members and payers, scoped to the trip. */
export function getBudgetItem(id: string | number, tripId: string | number): BudgetItem | null {
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId) as BudgetItem | undefined;
if (!item) return null;
item.members = loadItemMembers(id);
item.payers = loadItemPayers(id);
return item;
}
export function linkBudgetItemToReservation(
tripId: string | number,
reservationId: number,
@@ -219,15 +208,7 @@ export function updateBudgetItem(
);
// Optional inline payer/member replacement (the edit modal saves all at once).
if (data.payers !== undefined) {
writeItemPayers(id, data.payers);
// writeItemPayers derives total_price from the payer sum (0 for no payers).
// A "recorded total, nobody assigned" expense clears payers but still carries
// an explicit total_price — re-apply it so it isn't clobbered to 0.
if (data.payers.length === 0 && data.total_price !== undefined) {
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(data.total_price, id);
}
}
if (data.payers !== undefined) writeItemPayers(id, data.payers);
if (data.member_ids !== undefined) {
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
@@ -394,18 +375,11 @@ export function calculateSettlement(
}
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
// the receiver's credit shrinks, so the corresponding flow disappears. A transfer
// counts even when neither user has an expense-derived balance yet — a manual
// payment, or one left behind after its expense was deleted, then correctly
// surfaces as an amount still to square up instead of silently vanishing.
// the receiver's credit shrinks, so the corresponding flow disappears.
const settlements = listSettlements(tripId);
const ensureSettled = (id: number, username: string | undefined, avatar_url: string | null | undefined) => {
if (!balances[id]) balances[id] = { user_id: id, username: username || '', avatar_url: avatar_url ?? null, balance: 0 };
return balances[id];
};
for (const s of settlements) {
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += s.amount;
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= s.amount;
if (balances[s.from_user_id]) balances[s.from_user_id].balance += s.amount;
if (balances[s.to_user_id]) balances[s.to_user_id].balance -= s.amount;
}
// Calculate optimized payment flows (greedy algorithm)
@@ -477,19 +451,6 @@ export function createSettlement(
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
}
export function updateSettlement(
id: string | number,
tripId: string | number,
data: { from_user_id: number; to_user_id: number; amount: number },
) {
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!row) return null;
db.prepare(
'UPDATE budget_settlements SET from_user_id = ?, to_user_id = ?, amount = ? WHERE id = ?'
).run(data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, id);
return listSettlements(tripId).find(s => s.id === Number(id)) || null;
}
export function deleteSettlement(id: string | number, tripId: string | number): boolean {
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!row) return false;
+6 -11
View File
@@ -229,17 +229,12 @@ export function getPollWithVotes(pollId: number | bigint | string) {
WHERE v.poll_id = ?
`).all(pollId) as PollVoteRow[];
const formattedOptions = options.map((label: string | { label: string }, idx: number) => {
const text = typeof label === 'string' ? label : label.label || label;
return {
// The client renders `opt.text`; keep `label` too for any other consumer.
text,
label: text,
voters: votes
.filter(v => v.option_index === idx)
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
};
});
const formattedOptions = options.map((label: string | { label: string }, idx: number) => ({
label: typeof label === 'string' ? label : label.label || label,
voters: votes
.filter(v => v.option_index === idx)
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
}));
return {
...poll,
+31 -45
View File
@@ -986,73 +986,59 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> {
let resolvedUrl = url;
// Extract coordinates from a string (URL or page body). Google Maps encodes
// them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=.
const extractCoords = (s: string): { lat: number; lng: number } | null => {
const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
if (at) return { lat: parseFloat(at[1]), lng: parseFloat(at[2]) };
const data = s.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
if (data) return { lat: parseFloat(data[1]), lng: parseFloat(data[2]) };
const q = s.match(/[?&](?:q|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/);
if (q) return { lat: parseFloat(q[1]), lng: parseFloat(q[2]) };
return null;
};
const followRedirects = async (target: string, init?: RequestInit): Promise<Response> => {
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) with SSRF protection.
// Redirects are followed manually so every hop is re-checked — a short link
// that 302s to an internal IP is blocked, while a legitimate cross-host
// redirect (goo.gl → maps.google.com) still resolves.
const parsed = new URL(url);
if (['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname)) {
try {
return await safeFetchFollow(
target,
{ signal: AbortSignal.timeout(10000), ...init },
const redirectRes = await safeFetchFollow(
url,
{ signal: AbortSignal.timeout(10000) },
{ bypassInternalIpAllowed: true },
);
resolvedUrl = redirectRes.url;
} catch (err) {
if (err instanceof SsrfBlockedError) {
throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
}
throw err;
}
};
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) and for Google Maps
// URLs that carry no inline coordinates — e.g. ?cid= links (the format
// get_place_details returns) and "Share"-button links. The redirect target
// usually carries the !3d!4d data param we can then parse. Redirects are
// followed manually so every hop is SSRF-re-checked.
const parsed = new URL(url);
const GOOGLE_MAPS_HOSTS = ['goo.gl', 'maps.app.goo.gl', 'google.com', 'www.google.com', 'maps.google.com'];
const isShort = ['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname);
const isGoogleMaps = GOOGLE_MAPS_HOSTS.includes(parsed.hostname);
if (isShort || (isGoogleMaps && !extractCoords(url))) {
resolvedUrl = (await followRedirects(url)).url || resolvedUrl;
}
let coords = extractCoords(resolvedUrl);
// Extract coordinates from Google Maps URL patterns:
// /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522
// ?q=48.8566,2.3522 or ?ll=48.8566,2.3522
let lat: number | null = null;
let lng: number | null = null;
let placeName: string | null = null;
// Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the
// page body once and parse the coordinates out of the embedded map data.
if (!coords) {
try {
const pageRes = await followRedirects(resolvedUrl, {
headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' },
});
coords = extractCoords(await pageRes.text());
} catch (err) {
if ((err as { status?: number })?.status === 403) throw err; // SSRF block — surface it
// Otherwise fall through to the not-found error below.
}
// Pattern: /@lat,lng
const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
// Pattern: !3dlat!4dlng (Google Maps data params)
if (!lat) {
const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
}
// Pattern: ?q=lat,lng or &q=lat,lng
if (!lat) {
const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); }
}
// Extract place name from URL path: /place/Place+Name/@...
let placeName: string | null = null;
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
if (placeMatch) {
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
}
if (!coords || isNaN(coords.lat) || isNaN(coords.lng)) {
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
}
const { lat, lng } = coords;
// Reverse geocode to get address
const nominatimRes = await fetch(
+4 -18
View File
@@ -385,20 +385,8 @@ export function findOrCreateUser(
if (process.env.OIDC_ADMIN_VALUE) {
const newRole = resolveOidcRole(userInfo, false);
if (user.role !== newRole) {
// Never let the claim-based downgrade strip the last admin. The bootstrap
// admin (first SSO user) usually doesn't carry the admin claim, so a forced
// re-login — e.g. after a JWT-secret rotation — would otherwise demote it and
// lock an OIDC-only instance out for good. #1274
const demotingLastAdmin =
user.role === 'admin' &&
newRole !== 'admin' &&
(db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count <= 1;
if (demotingLastAdmin) {
console.warn(`[OIDC] Kept admin role for user ${user.id}: their OIDC claims map to '${newRole}', but they are the only admin — demoting would lock the instance out.`);
} else {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
user = { ...user, role: newRole } as User;
}
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
user = { ...user, role: newRole } as User;
}
}
return { user };
@@ -429,10 +417,8 @@ export function findOrCreateUser(
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync(randomPass, 10);
// Username: sanitize and avoid collisions. Keep dots — they are valid in
// usernames (see the ^[a-zA-Z0-9_.-]+$ validation in authService) and common
// in OIDC name claims like "first.last".
let username = name.replace(/[^a-zA-Z0-9_.-]/g, '').substring(0, 30) || 'user';
// Username: sanitize and avoid collisions
let username = name.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user';
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
if (existing) username = `${username}_${Date.now() % 10000}`;
+6 -19
View File
@@ -17,9 +17,9 @@ export interface ReservationEndpoint {
local_date: string | null;
}
export type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
export function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
const rows = db.prepare(`
SELECT e.* FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
@@ -110,9 +110,6 @@ export function listReservations(tripId: string | number) {
for (const r of reservations) {
r.day_positions = posMap.get(r.id) || null;
r.endpoints = endpointsMap.get(r.id) || [];
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
r.accommodation_id = r.accommodation_id == null ? null : Math.trunc(Number(r.accommodation_id));
}
return reservations;
@@ -166,9 +163,6 @@ export function getReservationWithJoins(id: string | number) {
`).get(id) as any;
if (!row) return undefined;
row.endpoints = loadEndpoints(row.id);
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
row.accommodation_id = row.accommodation_id == null ? null : Math.trunc(Number(row.accommodation_id));
return row;
}
@@ -370,19 +364,12 @@ export function updateReservation(id: string | number, tripId: string | number,
// otherwise derive from the (possibly updated) reservation_time so the
// planner renders the booking on the correct day.
let nextDayId: number | null;
if (day_id != null) {
// Explicit day from the client (e.g. moved on the planner).
nextDayId = day_id;
} else if (resolvedType !== 'hotel' && nextReservationTime) {
// No day set but we have a date — pin it to the matching day so the booking
// still shows in the Plan (covers bookings saved without a selected day, and
// the case where an earlier edit cleared day_id).
if (day_id !== undefined) {
nextDayId = day_id || null;
} else if (reservation_time !== undefined && resolvedType !== 'hotel') {
nextDayId = resolveDayIdFromTime(tripId, nextReservationTime);
} else if (day_id === undefined) {
// Field absent and nothing to derive from — keep whatever it had.
nextDayId = current.day_id ?? null;
} else {
nextDayId = null;
nextDayId = current.day_id ?? null;
}
let nextEndDayId: number | null;
-3
View File
@@ -10,9 +10,6 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
'temperature_unit',
'dark_mode',
'time_format',
// Instance-wide default currency for Costs (new users inherit it until they
// pick their own). Free-form ISO code, validated on the client.
'default_currency',
'blur_booking_codes',
'map_tile_url',
// Instance-wide Mapbox defaults: an admin can set a shared token + style so the
+18 -52
View File
@@ -5,7 +5,7 @@ import { Trip, User } from '../types';
import { listDays, listAccommodations } from './dayService';
import { listBudgetItems } from './budgetService';
import { listItems as listPackingItems } from './packingService';
import { listReservations, loadEndpointsByTrip } from './reservationService';
import { listReservations } from './reservationService';
import { listNotes as listCollabNotes } from './collabService';
import { shiftOwnerEntriesForTripWindow } from './vacayService';
@@ -516,54 +516,27 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
}
}
// Transport/flight reservations carry no top-level reservation_time; their
// times live per endpoint (local_date + local_time) in reservation_endpoints.
const endpointsMap = loadEndpointsByTrip(tripId);
const isDate = (s: string | null | undefined) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
const isTime = (s: string | null | undefined) => !!s && /^\d{2}:\d{2}/.test(s);
// Build the DTSTART/DTEND lines for a reservation, or null when it has no
// calendar-placeable time. Hotels/restaurants use reservation_time; flights
// fall back to their first/last endpoint.
const buildReservationTimeLines = (r: any): string | null => {
if (r.reservation_time) {
const datePart = r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time;
if (!isDate(datePart)) return null; // time-only (relative "Day N" trips)
if (r.reservation_time.includes('T')) {
let out = `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
if (r.reservation_end_time) {
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
if (endDt.length >= 15) out += `DTEND:${endDt}\r\n`;
}
return out;
}
return `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
}
const eps = endpointsMap.get(r.id);
if (!eps || eps.length === 0) return null;
const ordered = [...eps].sort((a, b) => a.sequence - b.sequence);
const first = ordered[0];
const last = ordered[ordered.length - 1];
if (!isDate(first.local_date)) return null;
if (isTime(first.local_time)) {
let out = `DTSTART:${fmtDateTime(`${first.local_date}T${first.local_time}`)}\r\n`;
if (last !== first && isDate(last.local_date) && isTime(last.local_time)) {
out += `DTEND:${fmtDateTime(`${last.local_date}T${last.local_time}`)}\r\n`;
}
return out;
}
return `DTSTART;VALUE=DATE:${fmtDate(first.local_date)}\r\n`;
};
// Reservations as events
for (const r of reservations) {
const timeLines = buildReservationTimeLines(r);
if (!timeLines) continue;
if (!r.reservation_time) continue;
// Skip time-only values (no calendar date — occurs on relative "Day N" trips)
const hasDate = r.reservation_time.includes('T')
? /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time.split('T')[0])
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time);
if (!hasDate) continue;
const hasTime = r.reservation_time.includes('T');
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
ics += timeLines;
if (hasTime) {
ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
if (r.reservation_end_time) {
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`;
}
} else {
ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
}
ics += `SUMMARY:${esc(r.title)}\r\n`;
let desc = r.type ? `Type: ${r.type}` : '';
@@ -574,16 +547,9 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
} else if (meta.departure_airport || meta.arrival_airport) {
} else {
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
} else {
// Endpoint-based transport without route metadata: derive it from endpoints.
const eps = endpointsMap.get(r.id);
if (eps && eps.length > 1) {
const stops = [...eps].sort((a, b) => a.sequence - b.sequence).map(e => e.code || e.name).filter(Boolean);
if (stops.length > 1) desc += `\nRoute: ${stops.join(' → ')}`;
}
}
if (meta.train_number) desc += `\nTrain: ${meta.train_number}`;
if (r.notes) desc += `\n${r.notes}`;
-15
View File
@@ -31,7 +31,6 @@ const { svc } = vi.hoisted(() => ({
verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(),
deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: vi.fn(),
calculateSettlement: vi.fn(), reorderBudgetItems: vi.fn(), reorderBudgetCategories: vi.fn(),
setItemPayers: vi.fn(), listSettlements: vi.fn(), createSettlement: vi.fn(), updateSettlement: vi.fn(), deleteSettlement: vi.fn(),
},
}));
vi.mock('../../src/services/budgetService', () => svc);
@@ -105,18 +104,4 @@ describe('Budget e2e (real auth guard + temp SQLite)', () => {
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'user_ids must be an array' });
});
it('200 on settlement update with permission', async () => {
svc.updateSettlement.mockReturnValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
expect(res.status).toBe(200);
expect(res.body).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
});
it('404 on settlement update when it does not exist', async () => {
svc.updateSettlement.mockReturnValue(null);
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Settlement not found' });
});
});
@@ -122,17 +122,4 @@ describe('BOOTSTRAP (F6) — unified NestJS app serves the whole surface', () =>
else process.env.NODE_ENV = prev;
}
});
it('BOOT-008 — large responses are gzip-compressed (Atlas country GeoJSON, #1254)', async () => {
// The admin-0 country GeoJSON is multi-MB; without compression it stalls
// behind reverse proxies / Cloudflare Tunnel. Proves applyGlobalMiddleware
// gzips it on the wire.
const { user } = createUser(testDb);
const res = await request(instance)
.get('/api/addons/atlas/countries/geo')
.set('Accept-Encoding', 'gzip')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.headers['content-encoding']).toBe('gzip');
});
});
+3 -115
View File
@@ -185,44 +185,6 @@ describe('Update reservation', () => {
expect(res.body.reservation.confirmation_number).toBe('ABC123');
});
it('RESV-004b — PUT with day_id null derives day_id from reservation_time so it stays in the Plan (#1237)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createDay(testDb, trip.id, { date: '2025-09-01' });
const day2 = createDay(testDb, trip.id, { date: '2025-09-02' });
const resv = createReservation(testDb, trip.id, { title: 'Event', type: 'event' });
const res = await request(app)
.put(`/api/trips/${trip.id}/reservations/${resv.id}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Event', type: 'event', day_id: null, reservation_time: '2025-09-02' });
expect(res.status).toBe(200);
expect(res.body.reservation.day_id).toBe(day2.id);
});
it('RESV-004c — re-dating a booking moves it to the matching day (start + end) (#1237)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day1 = createDay(testDb, trip.id, { date: '2025-10-01' });
const day3 = createDay(testDb, trip.id, { date: '2025-10-03' });
// Booking sits on day 1 (start + end).
const created = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Event', type: 'event', day_id: day1.id, reservation_time: '2025-10-01T09:00', reservation_end_time: '2025-10-01T10:00' });
const rid = created.body.reservation.id;
// Re-date to day 3 WITHOUT sending day_id (the modal omits it) — both ends follow.
const res = await request(app)
.put(`/api/trips/${trip.id}/reservations/${rid}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Event', type: 'event', reservation_time: '2025-10-03T00:00', reservation_end_time: '2025-10-03T14:00' });
expect(res.status).toBe(200);
expect(res.body.reservation.day_id).toBe(day3.id);
expect(res.body.reservation.end_day_id).toBe(day3.id);
});
it('RESV-004 — PUT on non-existent reservation returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -420,7 +382,7 @@ describe('Reservation budget entry integration', () => {
expect(items[0].total_price).toBe(150);
});
it('RESV-014 — PUT without create_budget_entry keeps the existing linked budget item', async () => {
it('RESV-014 — PUT without create_budget_entry removes existing linked budget item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -436,98 +398,24 @@ describe('Reservation budget entry integration', () => {
expect(createRes.status).toBe(201);
const resvId = createRes.body.reservation.id;
// Verify budget item exists
const before = testDb
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId);
expect(before).toBeDefined();
// Update WITHOUT create_budget_entry — the booking edit must NOT touch its
// linked expense (expenses are managed from the Costs section now).
// Update without create_budget_entry — should delete the linked budget item
const updateRes = await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Taxi Updated' });
expect(updateRes.status).toBe(200);
const after = testDb
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId);
expect(after).toBeDefined();
});
it('RESV-014b — PUT with create_budget_entry total_price 0 removes the linked budget item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({
title: 'Taxi',
type: 'transport',
create_budget_entry: { total_price: 50, category: 'Transport' },
});
expect(createRes.status).toBe(201);
const resvId = createRes.body.reservation.id;
// Explicit clear (total_price 0) still removes the linked item.
const updateRes = await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Taxi', create_budget_entry: { total_price: 0 } });
expect(updateRes.status).toBe(200);
const after = testDb
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId);
expect(after).toBeUndefined();
});
it('RESV-014c — changing the booking type updates the linked expense category', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Booking', type: 'other', create_budget_entry: { total_price: 50, category: 'other' } });
const resvId = createRes.body.reservation.id;
// Change the type other -> hotel (no create_budget_entry).
await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Booking', type: 'hotel' });
const item = testDb
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId) as { category: string };
expect(item.category).toBe('accommodation');
});
it('RESV-014d — a manually-picked expense category survives a booking type change', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Booking', type: 'other', create_budget_entry: { total_price: 50, category: 'other' } });
const resvId = createRes.body.reservation.id;
// Simulate a manual category pick in the Costs editor.
testDb.prepare('UPDATE budget_items SET category = ? WHERE trip_id = ? AND reservation_id = ?').run('fees', trip.id, resvId);
await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Booking', type: 'hotel' });
const item = testDb
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId) as { category: string };
expect(item.category).toBe('fees');
});
});
describe('Reservation accommodation delete', () => {
@@ -128,12 +128,10 @@ describe('Tool: mark_region_visited', () => {
arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' },
});
const data = parseToolResult(result) as any;
// Echoed in the client-facing shape ({ code, name, ... }), not raw DB columns.
expect(data.region).toBeDefined();
expect(data.region.code).toBe('US-CA');
expect(data.region.name).toBe('California');
expect(data.region.region_code).toBe('US-CA');
expect(data.region.region_name).toBe('California');
expect(data.region.country_code).toBe('US');
expect(data.region.manuallyMarked).toBe(true);
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA');
expect(row).toBeTruthy();
});
@@ -37,7 +37,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
@@ -70,7 +70,7 @@ async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promis
// ---------------------------------------------------------------------------
describe('Tool: set_budget_item_members', () => {
it('sets members and returns a hydrated item with members/payers', async () => {
it('sets members and broadcasts budget:members-updated', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 500 });
@@ -80,25 +80,11 @@ describe('Tool: set_budget_item_members', () => {
arguments: { tripId: trip.id, itemId: item.id, userIds: [user.id] },
});
const data = parseToolResult(result) as any;
// Regression: returns a hydrated item, not the raw row from updateMembers.
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
expect(Array.isArray(data.item.payers)).toBe(true);
expect(data.item).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.any(Object));
});
});
it('returns an error for an item not in the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_budget_item_members',
arguments: { tripId: trip.id, itemId: 99999, userIds: [user.id] },
});
expect(result.isError).toBe(true);
});
});
it('empty array clears members', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -145,58 +131,6 @@ describe('Tool: set_budget_item_members', () => {
});
});
// ---------------------------------------------------------------------------
// create_budget_item_with_members
// ---------------------------------------------------------------------------
describe('Tool: create_budget_item_with_members', () => {
it('assigns the given members and returns a hydrated item', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item_with_members',
arguments: { tripId: trip.id, name: 'Villa', total_price: 800, userIds: [user.id, member.id] },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id).sort()).toEqual([user.id, member.id].sort());
expect(data.item.persons).toBe(2);
});
});
// Regression: omitting userIds previously produced an empty-member (unsaveable) entity.
it('defaults to all trip members when userIds omitted', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item_with_members',
arguments: { tripId: trip.id, name: 'Shared cab', total_price: 50 },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id).sort()).toEqual([user.id, member.id].sort());
expect(data.item.members.length).toBeGreaterThan(0);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item_with_members',
arguments: { tripId: trip.id, name: 'X', total_price: 1 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// toggle_budget_member_paid
// ---------------------------------------------------------------------------
@@ -234,115 +168,6 @@ describe('Tool: toggle_budget_member_paid', () => {
});
});
// ---------------------------------------------------------------------------
// Settlements (settle-up payments)
// ---------------------------------------------------------------------------
describe('Settlement tools', () => {
function tripWithTwo() {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, other.id);
return { user, other, trip };
}
it('create_settlement records a payment, broadcasts, and is listed', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const created = await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 42.5 },
});
const cData = parseToolResult(created) as any;
expect(cData.settlement.from_user_id).toBe(other.id);
expect(cData.settlement.to_user_id).toBe(user.id);
expect(cData.settlement.amount).toBe(42.5);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-created', expect.any(Object));
const listed = await h.client.callTool({ name: 'list_settlements', arguments: { tripId: trip.id } });
const lData = parseToolResult(listed) as any;
expect(lData.settlements).toHaveLength(1);
expect(lData.settlements[0].id).toBe(cData.settlement.id);
});
});
it('update_settlement changes the amount; delete_settlement removes it', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 10 },
})) as any;
const id = created.settlement.id;
const updated = parseToolResult(await h.client.callTool({
name: 'update_settlement',
arguments: { tripId: trip.id, settlementId: id, from_user_id: other.id, to_user_id: user.id, amount: 25 },
})) as any;
expect(updated.settlement.amount).toBe(25);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-updated', expect.any(Object));
const deleted = parseToolResult(await h.client.callTool({
name: 'delete_settlement',
arguments: { tripId: trip.id, settlementId: id },
})) as any;
expect(deleted.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-deleted', expect.any(Object));
const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_settlements WHERE trip_id = ?').get(trip.id) as any;
expect(remaining.cnt).toBe(0);
});
});
it('update_settlement returns an error when the settlement is missing', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_settlement',
arguments: { tripId: trip.id, settlementId: 99999, from_user_id: other.id, to_user_id: user.id, amount: 5 },
});
expect(result.isError).toBe(true);
});
});
it('create_settlement is denied for a non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: other.id, amount: 5 },
});
expect(result.isError).toBe(true);
});
});
it('get_settlement_summary returns balances and flows', async () => {
// Avoid a real exchange-rate network call: force getRates() to fail closed.
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
try {
const { user, other, trip } = tripWithTwo();
// user paid 100 for an item split between both → other owes user 50.
const item = createBudgetItem(testDb, trip.id, { total_price: 100 });
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)')
.run(item.id, user.id, item.id, other.id);
testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)')
.run(item.id, user.id, 100);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.summary).toBeDefined();
expect(Array.isArray(data.summary.balances)).toBe(true);
expect(Array.isArray(data.summary.flows)).toBe(true);
});
} finally {
vi.unstubAllGlobals();
}
});
});
// ---------------------------------------------------------------------------
// Per-person resource
// ---------------------------------------------------------------------------
+1 -84
View File
@@ -35,7 +35,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
@@ -101,89 +101,6 @@ describe('Tool: create_budget_item', () => {
});
});
// Regression for #1244: a naive create must seed members so the client save-gate
// (participants.size > 0) passes — the entry must be saveable, not member-less.
it('defaults members to the trip owner when member_ids omitted (solo trip)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Dinner', total_price: 40 },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
expect(data.item.persons).toBe(1);
// saveable invariant: client requires participants.size > 0
expect(data.item.members.length).toBeGreaterThan(0);
});
});
it('defaults members to all trip members when member_ids omitted (multi-member)', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Group taxi', total_price: 60 },
});
const data = parseToolResult(result) as any;
const ids = data.item.members.map((m: any) => m.user_id).sort();
expect(ids).toEqual([user.id, member.id].sort());
expect(data.item.persons).toBe(2);
});
});
it('respects an explicit member_ids subset', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'My snack', total_price: 5, member_ids: [user.id] },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
});
});
it('treats an explicit empty member_ids as a planning-only entry (no split)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Estimate', total_price: 100, member_ids: [] },
});
const data = parseToolResult(result) as any;
expect(data.item.members).toEqual([]);
});
});
it('round-trips currency, expense_date, and payers', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id, name: 'Museum', total_price: 30, currency: 'EUR',
expense_date: '2026-07-01', payers: [{ user_id: user.id, amount: 30 }],
},
});
const data = parseToolResult(result) as any;
expect(data.item.currency).toBe('EUR');
expect(data.item.expense_date).toBe('2026-07-01');
expect(data.item.payers.map((p: any) => p.user_id)).toEqual([user.id]);
// total_price derives from payer sum
expect(data.item.total_price).toBe(30);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
@@ -168,14 +168,12 @@ describe('Tool: create_accommodation', () => {
start_day_id: day1.id,
end_day_id: day2.id,
check_in: '15:00',
check_in_end: '20:00',
check_out: '11:00',
confirmation: 'CONF123',
},
});
const data = parseToolResult(result) as any;
expect(data.accommodation).toBeDefined();
expect(data.accommodation.check_in_end).toBe('20:00');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
});
});
-146
View File
@@ -1,146 +0,0 @@
/**
* Unit tests for MCP journey write tools focused on response hydration:
* create_journey returns the full journey (entries/contributors/trips/stats/my_role),
* and create_journey_entry returns the enriched entry (parsed tags, photos array).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock, broadcastToUser: broadcastMock }));
vi.mock('../../../src/services/adminService', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return { ...original, isAddonEnabled: vi.fn().mockReturnValue(true) };
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
describe('Tool: create_journey', () => {
it('returns the fully-hydrated journey, not a bare row', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_journey',
arguments: { title: 'Eurotrip', subtitle: '2026' },
});
const data = parseToolResult(result) as any;
expect(data.journey.title).toBe('Eurotrip');
// hydrated shape from getJourneyFull
expect(Array.isArray(data.journey.entries)).toBe(true);
expect(Array.isArray(data.journey.contributors)).toBe(true);
expect(Array.isArray(data.journey.trips)).toBe(true);
expect(data.journey.stats).toBeDefined();
expect(data.journey.my_role).toBeDefined();
});
});
});
describe('Tool: create_journey_entry', () => {
it('returns the enriched entry with parsed tags and a photos array', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = (parseToolResult(await h.client.callTool({
name: 'create_journey', arguments: { title: 'J' },
})) as any).journey;
const result = await h.client.callTool({
name: 'create_journey_entry',
arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Day 1', story: 'Arrived' },
});
const data = parseToolResult(result) as any;
expect(data.entry.title).toBe('Day 1');
// listEntries enrichment: tags parsed to an array, photos present
expect(Array.isArray(data.entry.tags)).toBe(true);
expect(Array.isArray(data.entry.photos)).toBe(true);
expect(data.entry).toHaveProperty('source_trip_name');
});
});
});
describe('Tool: update_journey_entry', () => {
it('returns the enriched entry (parsed tags, photos array)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = (parseToolResult(await h.client.callTool({
name: 'create_journey', arguments: { title: 'J' },
})) as any).journey;
const entry = (parseToolResult(await h.client.callTool({
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Day 1' },
})) as any).entry;
const result = await h.client.callTool({
name: 'update_journey_entry',
arguments: { entryId: entry.id, title: 'Day 1 (edited)' },
});
const data = parseToolResult(result) as any;
expect(data.entry.title).toBe('Day 1 (edited)');
expect(Array.isArray(data.entry.tags)).toBe(true);
expect(Array.isArray(data.entry.photos)).toBe(true);
});
});
});
describe('Tool: update_journey_preferences', () => {
it('returns the updated preference, not { success }', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = (parseToolResult(await h.client.callTool({
name: 'create_journey', arguments: { title: 'J' },
})) as any).journey;
const result = await h.client.callTool({
name: 'update_journey_preferences',
arguments: { journeyId: journey.id, hide_skeletons: true },
});
const data = parseToolResult(result) as any;
expect(data.hide_skeletons).toBe(true);
});
});
});
@@ -39,7 +39,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createTrip, createPackingItem } from '../../helpers/factories';
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
@@ -148,8 +148,6 @@ describe('Tool: create_packing_bag', () => {
const data = parseToolResult(result) as any;
expect(data.bag).toBeDefined();
expect(data.bag.name).toBe('Checked bag');
// hydrated to match listBags/schema, which always carry a members array
expect(data.bag.members).toEqual([]);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-created', expect.any(Object));
});
});
@@ -269,9 +267,8 @@ describe('Tool: set_bag_members', () => {
arguments: { tripId: trip.id, bagId, userIds: [user.id] },
});
const data = parseToolResult(result) as any;
// Returns the hydrated members list (REST parity), not { success }.
expect(data.members.map((m: any) => m.user_id)).toEqual([user.id]);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.objectContaining({ members: expect.any(Array) }));
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.any(Object));
});
});
@@ -287,7 +284,7 @@ describe('Tool: set_bag_members', () => {
arguments: { tripId: trip.id, bagId, userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.members).toEqual([]);
expect(data.success).toBe(true);
});
});
});
@@ -325,9 +322,8 @@ describe('Tool: set_packing_category_assignees', () => {
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [user.id] },
});
const data = parseToolResult(result) as any;
// Returns the hydrated assignees list (REST parity), not { success }.
expect(data.assignees.map((a: any) => a.user_id)).toEqual([user.id]);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.objectContaining({ category: 'Clothing', assignees: expect.any(Array) }));
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.any(Object));
});
});
@@ -341,7 +337,7 @@ describe('Tool: set_packing_category_assignees', () => {
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.assignees).toEqual([]);
expect(data.success).toBe(true);
});
});
@@ -382,36 +378,7 @@ describe('Tool: apply_packing_template', () => {
// ---------------------------------------------------------------------------
describe('Tool: save_packing_template', () => {
it('saves the current packing list as a template for an admin', async () => {
const { user } = createAdmin(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
});
const data = parseToolResult(result) as any;
// Save now returns the new template (with its id) instead of a bare success flag.
expect(data.template).toBeDefined();
expect(Number.isInteger(data.template.id)).toBe(true);
expect(data.template.name).toBe('Weekend Trip');
});
});
it('returns an error when the packing list is empty', async () => {
const { user } = createAdmin(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Empty' },
});
expect(result.isError).toBe(true);
});
});
it('denies a non-admin editor (parity with the REST admin gate)', async () => {
it('saves the current packing list as a template', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
@@ -420,8 +387,8 @@ describe('Tool: save_packing_template', () => {
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
});
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toBe('Admin access required');
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
@@ -439,96 +406,12 @@ describe('Tool: save_packing_template', () => {
});
});
// ---------------------------------------------------------------------------
// list_packing_templates / delete_packing_template
// ---------------------------------------------------------------------------
describe('Tool: list_packing_templates', () => {
it('lists saved templates with their ids and item counts', async () => {
const { user } = createAdmin(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
await withHarness(user.id, async (h) => {
const saved = parseToolResult(await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Beach' },
})) as any;
const listed = parseToolResult(await h.client.callTool({
name: 'list_packing_templates',
arguments: { tripId: trip.id },
})) as any;
expect(listed.templates.some((t: any) => t.id === saved.template.id && t.name === 'Beach')).toBe(true);
});
});
it('is available to a non-admin trip member (read-only)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_packing_templates',
arguments: { tripId: trip.id },
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as any;
expect(Array.isArray(data.templates)).toBe(true);
});
});
});
describe('Tool: delete_packing_template', () => {
it('removes a template for an admin', async () => {
const { user } = createAdmin(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
await withHarness(user.id, async (h) => {
const saved = parseToolResult(await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Ski' },
})) as any;
const id = saved.template.id;
const deleted = parseToolResult(await h.client.callTool({
name: 'delete_packing_template',
arguments: { templateId: id },
})) as any;
expect(deleted.success).toBe(true);
const remaining = testDb.prepare('SELECT count(*) as cnt FROM packing_templates WHERE id = ?').get(id) as any;
expect(remaining.cnt).toBe(0);
});
});
it('denies a non-admin (parity with the REST admin gate)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_packing_template',
arguments: { templateId: 1 },
});
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toBe('Admin access required');
});
});
it('returns an error for a missing template', async () => {
const { user } = createAdmin(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_packing_template',
arguments: { templateId: 99999 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// bulk_import_packing
// ---------------------------------------------------------------------------
describe('Tool: bulk_import_packing', () => {
it('imports multiple packing items, returns them, and broadcasts per item', async () => {
it('imports multiple packing items and count matches', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const items = [
@@ -542,33 +425,9 @@ describe('Tool: bulk_import_packing', () => {
arguments: { tripId: trip.id, items },
});
const data = parseToolResult(result) as any;
// New contract: returns the created items (REST parity), broadcasts packing:created per item.
expect(data.success).toBe(true);
expect(data.count).toBe(items.length);
expect(Array.isArray(data.items)).toBe(true);
expect(data.items).toHaveLength(items.length);
expect(data.items[0].name).toBe('Passport');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:created', expect.objectContaining({ item: expect.any(Object) }));
expect(broadcastMock).toHaveBeenCalledTimes(items.length);
});
});
it('honors the widened fields (bag, weight_grams, checked)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'bulk_import_packing',
arguments: {
tripId: trip.id,
items: [{ name: 'Tent', category: 'Camping', bag: 'Backpack', weight_grams: 2500, checked: true }],
},
});
const data = parseToolResult(result) as any;
expect(data.count).toBe(1);
const item = data.items[0];
expect(item.weight_grams).toBe(2500);
expect(item.checked).toBe(1);
expect(item.bag_id).toBeTruthy(); // "Backpack" bag was created and assigned
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
});
});
@@ -350,10 +350,6 @@ describe('Tool: link_hotel_accommodation', () => {
const data = parseToolResult(result) as any;
expect(data.reservation.accommodation_id).not.toBeNull();
expect(data.accommodation_id).not.toBeNull();
// accommodation_id must be a clean integer, not a stringified float ("14.0").
expect(typeof data.reservation.accommodation_id).toBe('number');
expect(Number.isInteger(data.reservation.accommodation_id)).toBe(true);
expect(Number.isInteger(data.accommodation_id)).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
});
});
@@ -1,201 +0,0 @@
/**
* Unit tests for MCP transport tools: create_transport, update_transport, delete_transport.
* Focus: flight endpoints supplied with only an IATA `code` are backfilled with
* lat/lng/timezone from the airport database (the columns are NOT NULL), and
* endpoints that can't be resolved produce a clean error instead of a SQL crash.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
const flightEndpoints = [
{ role: 'from', sequence: 0, name: 'Zurich', code: 'ZRH' },
{ role: 'to', sequence: 1, name: 'Paris CDG', code: 'CDG' },
];
describe('Tool: create_transport', () => {
it('backfills lat/lng/timezone for code-only flight endpoints', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG', endpoints: flightEndpoints },
});
const data = parseToolResult(result) as any;
const eps = data.reservation.endpoints;
expect(eps).toHaveLength(2);
const from = eps.find((e: any) => e.role === 'from');
expect(typeof from.lat).toBe('number');
expect(typeof from.lng).toBe('number');
expect(from.timezone).toBe('Europe/Zurich');
// persisted NOT NULL columns are populated
const rows = testDb.prepare('SELECT lat, lng FROM reservation_endpoints WHERE reservation_id = ?').all(data.reservation.id) as any[];
expect(rows.every(r => r.lat != null && r.lng != null)).toBe(true);
});
});
it('keeps manually-supplied coordinates and the caller timezone', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'train', title: 'Scenic train',
endpoints: [
{ role: 'from', sequence: 0, name: 'Station A', lat: 46.0, lng: 7.0, timezone: 'Europe/Zurich' },
{ role: 'to', sequence: 1, name: 'Station B', lat: 46.5, lng: 7.5 },
],
},
});
const data = parseToolResult(result) as any;
const from = data.reservation.endpoints.find((e: any) => e.role === 'from');
expect(from.lat).toBe(46.0);
expect(from.timezone).toBe('Europe/Zurich');
});
});
it('errors on an unresolvable airport code instead of crashing', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'Bad flight',
endpoints: [{ role: 'from', sequence: 0, name: 'Nowhere', code: 'ZZZ' }],
},
});
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toContain('ZZZ');
});
});
it('errors on an endpoint missing both coordinates and a code', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'car', title: 'Road trip',
endpoints: [{ role: 'from', sequence: 0, name: 'My house' }],
},
});
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toContain('missing coordinates');
});
});
it('creates a transport with no endpoints', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'flight', title: 'TBD flight' },
});
const data = parseToolResult(result) as any;
expect(data.reservation.title).toBe('TBD flight');
});
});
});
describe('Tool: update_transport', () => {
it('backfills coords when replacing endpoints', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'flight', title: 'F', endpoints: flightEndpoints },
})) as any;
const result = await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
endpoints: [
{ role: 'from', sequence: 0, name: 'JFK', code: 'JFK' },
{ role: 'to', sequence: 1, name: 'Zurich', code: 'ZRH' },
],
},
});
const data = parseToolResult(result) as any;
const from = data.reservation.endpoints.find((e: any) => e.role === 'from');
expect(from.code).toBe('JFK');
expect(typeof from.lat).toBe('number');
});
});
it('leaves endpoints untouched when not provided', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'flight', title: 'F', endpoints: flightEndpoints },
})) as any;
const result = await h.client.callTool({
name: 'update_transport',
arguments: { tripId: trip.id, reservationId: created.reservation.id, status: 'confirmed' },
});
const data = parseToolResult(result) as any;
expect(data.reservation.status).toBe('confirmed');
expect(data.reservation.endpoints).toHaveLength(2);
});
});
});
+4 -11
View File
@@ -49,9 +49,7 @@ vi.mock('../../../src/services/vacayService', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return {
...original,
updatePlan: vi.fn().mockResolvedValue({
plan: { id: 1, block_weekends: true, holidays_enabled: false, company_holidays_enabled: false, carry_over_enabled: false, holiday_calendars: [] },
}),
updatePlan: vi.fn().mockResolvedValue(undefined),
getCountries: vi.fn().mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }),
getHolidays: vi.fn().mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }),
};
@@ -108,7 +106,7 @@ describe('Tool: get_vacay_plan', () => {
// ---------------------------------------------------------------------------
describe('Tool: update_vacay_plan', () => {
it('calls updatePlan and returns the hydrated plan', async () => {
it('calls updatePlan and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -116,11 +114,7 @@ describe('Tool: update_vacay_plan', () => {
arguments: { block_weekends: true, holidays_enabled: false },
});
const data = parseToolResult(result) as any;
// Now returns the fully-hydrated plan (matching get_vacay_plan), not { success }.
expect(data.plan).toBeDefined();
expect(data.plan.block_weekends).toBe(true);
expect(data.plan.holidays_enabled).toBe(false);
expect(Array.isArray(data.plan.holiday_calendars)).toBe(true);
expect(data.success).toBe(true);
});
});
@@ -142,10 +136,9 @@ describe('Tool: set_vacay_color', () => {
it('updates color and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#ff0000' } });
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#6366f1' } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.color).toBe('#ff0000'); // echoes the persisted color
});
});
@@ -111,37 +111,6 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
});
it('PUT /settlements/:id 403 without budget_edit', () => {
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
status: 403, body: { error: 'No permission' },
});
});
it('PUT /settlements/:id 400 when a field is missing', () => {
const svc = makeService();
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2 }))).toEqual({
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
});
});
it('PUT /settlements/:id 404 when missing', () => {
const svc = makeService({ updateSettlement: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
status: 404, body: { error: 'Settlement not found' },
});
});
it('PUT /settlements/:id updates and broadcasts', () => {
const updateSettlement = vi.fn().mockReturnValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
const broadcast = vi.fn();
const svc = makeService({ updateSettlement, broadcast } as Partial<BudgetService>);
const res = new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 2, to_user_id: 1, amount: 15 }, 'sock');
expect(res).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
expect(updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 2, to_user_id: 1, amount: 15 });
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-updated', { settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } }, 'sock');
});
});
describe('POST /', () => {

Some files were not shown because too many files have changed in this diff Show More