mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-18 19:36:02 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69537d047d | |||
| 68fae2d0fa | |||
| 04a6bba2f0 | |||
| 0c66834872 | |||
| a5522e99d8 | |||
| 6f20c11dff | |||
| 3ef122c843 | |||
| 094622f06d | |||
| 90a2833011 | |||
| d320007627 | |||
| a2d80e8752 | |||
| 634210f2b1 | |||
| eeba57fa67 | |||
| 91c9909dc4 | |||
| c3b14467d8 | |||
| 1cabbec11a | |||
| 75e3bb3985 | |||
| 819aa793ae |
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.1.3
|
||||
version: 3.1.4
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.1.3"
|
||||
appVersion: "3.1.4"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.1.3",
|
||||
"version": "3.1.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -18,6 +18,9 @@ import type {
|
||||
CollectionStatus,
|
||||
Collection,
|
||||
CollectionPlace,
|
||||
CollectionLabel,
|
||||
CollectionLabelCreateRequest,
|
||||
CollectionLabelUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
|
||||
const ax = apiClient
|
||||
@@ -95,4 +98,15 @@ export const collectionsApi = {
|
||||
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
|
||||
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
|
||||
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
|
||||
|
||||
createLabel: (collectionId: number, name: string, color?: string): Promise<CollectionLabel> =>
|
||||
ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise<CollectionLabel> =>
|
||||
ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
deleteLabel: (labelId: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data),
|
||||
assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ export const CURRENCIES = [
|
||||
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'KGS',
|
||||
'BDT', 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
]
|
||||
|
||||
export const SYMBOLS: Record<string, string> = {
|
||||
@@ -13,8 +13,8 @@ export const SYMBOLS: Record<string, string> = {
|
||||
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||
PEN: 'S/.', ARS: 'AR$',
|
||||
UAH: '₴', KGS: 'сом', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$',
|
||||
COP: 'CO$', PEN: 'S/.', ARS: 'AR$',
|
||||
}
|
||||
|
||||
export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Check, Loader2, Settings2, Tags } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionLabel } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
interface BulkAssignLabelModalProps {
|
||||
isOpen: boolean
|
||||
labels: CollectionLabel[]
|
||||
/** Number of selected places the labels will be added to. */
|
||||
count: number
|
||||
onAssign: (labelIds: number[]) => Promise<void> | void
|
||||
/** Open the label manager to create labels first. */
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one or more of the list's labels to add to every selected place. Additive
|
||||
* — it never removes labels a place already has. When the list has no labels yet,
|
||||
* it points the user at the label manager instead.
|
||||
*/
|
||||
export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign, onManage, onClose, t }: BulkAssignLabelModalProps): React.ReactElement {
|
||||
const [picked, setPicked] = useState<number[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const toggle = (id: number) => setPicked(picked.includes(id) ? picked.filter(x => x !== id) : [...picked, id])
|
||||
|
||||
const assign = async () => {
|
||||
if (picked.length === 0 || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onAssign(picked)
|
||||
setPicked([])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="sm">
|
||||
{labels.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Tags size={26} className="text-content-faint" />
|
||||
<p className="text-[13px] text-content-faint">{t('collections.labels.emptyHint')}</p>
|
||||
<button type="button" onClick={onManage} className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-[13px] text-content hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1 max-h-[46vh] overflow-y-auto -mx-1 px-1">
|
||||
{labels.map(l => {
|
||||
const on = picked.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => toggle(l.id)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors ${on ? 'border-accent bg-accent/10' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: l.color || '#6366f1' }} />
|
||||
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{l.name}</span>
|
||||
{on && <Check size={15} className="text-accent shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-edge">
|
||||
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={assign}
|
||||
disabled={picked.length === 0 || busy}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('collections.labels.assign')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,12 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
|
||||
categoryOptions: CATEGORY_OPTIONS,
|
||||
onStatusFilter: vi.fn(),
|
||||
onCategoryFilter: vi.fn(),
|
||||
showLabels: false,
|
||||
labelOptions: [],
|
||||
labelFilter: [],
|
||||
onLabelFilter: vi.fn(),
|
||||
canManageLabels: false,
|
||||
onManageLabels: vi.fn(),
|
||||
showSelect: true,
|
||||
selectMode: false,
|
||||
onToggleSelect: vi.fn(),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Check, Layers, Tag, CheckSquare } from 'lucide-react'
|
||||
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare } from 'lucide-react'
|
||||
import type { StatusFilter } from '../../store/collectionStore'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CategoryOption } from '../../pages/collections/collectionsModel'
|
||||
import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel'
|
||||
|
||||
interface Opt {
|
||||
key: string | number
|
||||
@@ -69,6 +69,13 @@ interface CollectionFilterBarProps {
|
||||
categoryOptions: CategoryOption[]
|
||||
onStatusFilter: (f: StatusFilter) => void
|
||||
onCategoryFilter: (f: number | 'all') => void
|
||||
// Per-collection labels (hidden on the "All saved" union).
|
||||
showLabels: boolean
|
||||
labelOptions: LabelOption[]
|
||||
labelFilter: number[]
|
||||
onLabelFilter: (ids: number[]) => void
|
||||
canManageLabels: boolean
|
||||
onManageLabels: () => void
|
||||
showSelect: boolean
|
||||
selectMode: boolean
|
||||
onToggleSelect: () => void
|
||||
@@ -82,6 +89,7 @@ interface CollectionFilterBarProps {
|
||||
*/
|
||||
export default function CollectionFilterBar({
|
||||
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
|
||||
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
|
||||
showSelect, selectMode, onToggleSelect, t,
|
||||
}: CollectionFilterBarProps): React.ReactElement {
|
||||
const statusOpts: Opt[] = [
|
||||
@@ -112,6 +120,33 @@ export default function CollectionFilterBar({
|
||||
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
|
||||
</button>
|
||||
)}
|
||||
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
|
||||
<div className="col-labelfilter">
|
||||
{labelOptions.map(l => {
|
||||
const on = labelFilter.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
className={`col-labelchip${on ? ' on' : ''}`}
|
||||
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
|
||||
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<span className="col-labelchip-dot" />
|
||||
<span className="col-filter-lbl">{l.name}</span>
|
||||
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canManageLabels && (
|
||||
<button type="button" className="col-labelchip col-labelchip-manage" onClick={onManageLabels} title={t('collections.labels.manage')}>
|
||||
<Tags size={13} />
|
||||
<span className="col-filter-lbl">{labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ function renderList(over: Partial<{
|
||||
render(
|
||||
<CollectionList
|
||||
places={places}
|
||||
labels={[]}
|
||||
selectedPlaceId={over.selectedPlaceId ?? null}
|
||||
selectMode={over.selectMode ?? false}
|
||||
selectedIds={over.selectedIds ?? []}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import { Check, MapPin } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -8,6 +8,7 @@ import StatusBadge from './StatusBadge'
|
||||
|
||||
interface CollectionListProps {
|
||||
places: CollectionPlace[]
|
||||
labels: CollectionLabel[]
|
||||
selectedPlaceId: number | null
|
||||
selectMode: boolean
|
||||
selectedIds: number[]
|
||||
@@ -23,8 +24,9 @@ interface CollectionListProps {
|
||||
* open the place (or toggle it in select mode).
|
||||
*/
|
||||
export default function CollectionList({
|
||||
places, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
|
||||
places, labels, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
|
||||
}: CollectionListProps): React.ReactElement {
|
||||
const labelsById = useMemo(() => new Map(labels.map(l => [l.id, l])), [labels])
|
||||
// Bring the selected row into view — e.g. when it was picked from the map.
|
||||
const selectedRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
@@ -36,6 +38,7 @@ export default function CollectionList({
|
||||
{places.map(place => {
|
||||
const selected = selectedIds.includes(place.id)
|
||||
const active = selectedPlaceId === place.id
|
||||
const placeLabels = (place.label_ids ?? []).map(id => labelsById.get(id)).filter(Boolean) as CollectionLabel[]
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
@@ -61,6 +64,12 @@ export default function CollectionList({
|
||||
)}
|
||||
</div>
|
||||
<div className="col-lrow-end">
|
||||
{placeLabels.slice(0, 2).map(l => (
|
||||
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
|
||||
{place.category?.name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
|
||||
return (
|
||||
|
||||
@@ -39,6 +39,7 @@ function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
categories: [],
|
||||
labels: [],
|
||||
anchorRect: null,
|
||||
onClose: vi.fn(),
|
||||
onSetStatus: vi.fn(),
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
@@ -22,11 +22,13 @@ interface CollectionPlaceDetailProps {
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
categories: Category[]
|
||||
/** The active list's custom labels, for the assign chips. */
|
||||
labels: CollectionLabel[]
|
||||
/** When set, dock the sheet over that column (desktop split) instead of centred. */
|
||||
anchorRect?: { left: number; width: number } | null
|
||||
onClose: () => void
|
||||
onSetStatus: (status: CollectionStatus) => void
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null }) => Promise<void>
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[] }) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
t: TranslationFn
|
||||
@@ -56,7 +58,7 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet:
|
||||
* is an always-live segmented control (auto-saves).
|
||||
*/
|
||||
export default function CollectionPlaceDetail({
|
||||
place, canEdit, canDelete, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
}: CollectionPlaceDetailProps): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -64,6 +66,7 @@ export default function CollectionPlaceDetail({
|
||||
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
|
||||
const [description, setDescription] = useState(place.description ?? '')
|
||||
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
|
||||
const [labelIds, setLabelIds] = useState<number[]>(place.label_ids ?? [])
|
||||
const [saving, setSaving] = useState(false)
|
||||
// A higher-res photo pulled from the maps provider when the place has none of
|
||||
// its own — the list avatar's little thumbnail is too low-res for the cover.
|
||||
@@ -77,6 +80,7 @@ export default function CollectionPlaceDetail({
|
||||
setCategoryId(place.category_id ?? null)
|
||||
setDescription(place.description ?? '')
|
||||
setLinks(place.links ?? [])
|
||||
setLabelIds(place.label_ids ?? [])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [place.id])
|
||||
|
||||
@@ -96,13 +100,15 @@ export default function CollectionPlaceDetail({
|
||||
|
||||
const banner = place.image_url || fetchedPhoto
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []) }
|
||||
const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []) }
|
||||
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
|
||||
|
||||
const save = async () => {
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId })
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -161,6 +167,22 @@ export default function CollectionPlaceDetail({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><Tags size={12} /> {t('collections.labels.title')}</div>
|
||||
<div className="col-detail-cats">
|
||||
{labels.map(l => {
|
||||
const on = labelIds.includes(l.id)
|
||||
return (
|
||||
<button key={l.id} type="button" onClick={() => toggleLabel(l.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: l.color || '#6366f1' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Description */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label">{t('collections.description')}</div>
|
||||
@@ -184,6 +206,15 @@ export default function CollectionPlaceDetail({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{assignedLabels.length > 0 && (
|
||||
<div className="col-detail-labels">
|
||||
{assignedLabels.map(l => (
|
||||
<span key={l.id} className="col-labelchip on static" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="col-detail-md collab-note-md">
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description}</Markdown>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionLabel, CollectionLabelUpdateRequest } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
const SWATCHES = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#8b5cf6', '#64748b']
|
||||
|
||||
interface LabelManagerProps {
|
||||
isOpen: boolean
|
||||
labels: CollectionLabel[]
|
||||
onCreate: (name: string, color?: string) => Promise<void> | void
|
||||
onUpdate: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void> | void
|
||||
onDelete: (labelId: number) => Promise<void> | void
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/** Swatch row shared by the create form and each row's recolor control. */
|
||||
function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{SWATCHES.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onPick(c)}
|
||||
className={`w-5 h-5 rounded-full border transition-transform ${value.toLowerCase() === c ? 'border-content scale-110' : 'border-transparent'}`}
|
||||
style={{ background: c }}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One existing label: inline rename (save on blur/Enter), recolor, delete. */
|
||||
function LabelRow({ label, onUpdate, onDelete, t }: {
|
||||
label: CollectionLabel
|
||||
onUpdate: LabelManagerProps['onUpdate']
|
||||
onDelete: LabelManagerProps['onDelete']
|
||||
t: TranslationFn
|
||||
}): React.ReactElement {
|
||||
const [name, setName] = useState(label.name)
|
||||
const [color, setColor] = useState(label.color || '#6366f1')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const commitName = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || trimmed === label.name) { setName(label.name); return }
|
||||
setBusy(true)
|
||||
try { await onUpdate(label.id, { name: trimmed }) } finally { setBusy(false) }
|
||||
}
|
||||
const pickColor = async (c: string) => {
|
||||
setColor(c)
|
||||
setBusy(true)
|
||||
try { await onUpdate(label.id, { color: c }) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 rounded-xl border border-edge bg-surface-card">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: color }} />
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onBlur={commitName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||
maxLength={60}
|
||||
className="flex-1 min-w-0 bg-transparent text-[13px] text-content outline-none"
|
||||
aria-label={t('collections.labels.name')}
|
||||
/>
|
||||
<Swatches value={color} onPick={pickColor} />
|
||||
{busy && <Loader2 size={14} className="animate-spin text-content-faint shrink-0" />}
|
||||
<button type="button" onClick={() => onDelete(label.id)} className="p-1 text-content-faint hover:text-danger shrink-0" aria-label={t('common.delete')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage a list's custom labels — create, rename, recolor and delete. Available
|
||||
* to any member who can edit the list; the labels are shared by the whole list.
|
||||
*/
|
||||
export default function LabelManager({ isOpen, labels, onCreate, onUpdate, onDelete, onClose, t }: LabelManagerProps): React.ReactElement {
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newColor, setNewColor] = useState(SWATCHES[0])
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const add = async () => {
|
||||
const trimmed = newName.trim()
|
||||
if (!trimmed || adding) return
|
||||
setAdding(true)
|
||||
try {
|
||||
await onCreate(trimmed, newColor)
|
||||
setNewName('')
|
||||
setNewColor(SWATCHES[0])
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.manage')} size="sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
{labels.length === 0 ? (
|
||||
<p className="text-center text-[13px] text-content-faint py-4">{t('collections.labels.empty')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 max-h-[46vh] overflow-y-auto -mx-1 px-1">
|
||||
{labels.map(l => <LabelRow key={l.id} label={l} onUpdate={onUpdate} onDelete={onDelete} t={t} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-3 border-t border-edge">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: newColor }} />
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') add() }}
|
||||
maxLength={60}
|
||||
placeholder={t('collections.labels.namePlaceholder')}
|
||||
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
disabled={!newName.trim() || adding}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} {t('collections.labels.add')}
|
||||
</button>
|
||||
</div>
|
||||
<Swatches value={newColor} onPick={setNewColor} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,9 @@ vi.mock('react-leaflet', () => ({
|
||||
>
|
||||
<button
|
||||
data-testid="marker-hover-trigger"
|
||||
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
|
||||
// A real mouseover never bubbles as a click to the marker, so the
|
||||
// hover-simulation must not trigger the marker's click handler.
|
||||
onClick={(e: any) => { e.stopPropagation(); eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } }) }}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
@@ -245,6 +247,44 @@ describe('MapView', () => {
|
||||
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-021: clicking a marker clears the hover tooltip (#1404)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [buildMapPlace({ id: 3, name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} onMarkerClick={vi.fn()} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
||||
// The recenter that follows the click moves the marker out from under the
|
||||
// cursor — no mouseout will ever fire, so the click itself must clear.
|
||||
fireEvent.click(screen.getByTestId('marker'))
|
||||
expect(screen.queryByTestId('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-022: camera movement clears the tooltip and suppresses re-show until it ends (#1404)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [buildMapPlace({ id: 4, name: 'Louvre', lat: 48.86, lng: 2.337 })]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
||||
|
||||
const findHandler = (event: string) =>
|
||||
mapMock.on.mock.calls.find(c => c[0] === event)?.[1] as (() => void) | undefined
|
||||
const start = findHandler('movestart zoomstart')
|
||||
const end = findHandler('moveend zoomend')
|
||||
expect(start).toBeTypeOf('function')
|
||||
expect(end).toBeTypeOf('function')
|
||||
|
||||
fireEvent.click(screen.getByTestId('marker-hover-trigger')) // ensure hover is showing
|
||||
start!()
|
||||
await waitFor(() => expect(screen.queryByTestId('tooltip')).toBeNull())
|
||||
// during the pan animation a mouseover must not re-show the card
|
||||
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.queryByTestId('tooltip')).toBeNull()
|
||||
// once the move ends, hover works again
|
||||
end!()
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-020: a day fit expands to include the route once it arrives (#1128)', async () => {
|
||||
const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType<typeof vi.fn> }
|
||||
const dayPlaces = [
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import ReservationOverlay from './ReservationOverlay'
|
||||
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import type { Reservation } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
|
||||
@@ -139,6 +140,23 @@ function createPoiIcon(category: string) {
|
||||
return icon
|
||||
}
|
||||
|
||||
// Clears the hover tooltip the moment the camera starts moving and suppresses
|
||||
// re-showing it until the move ends: after a click-recenter the marker slides
|
||||
// away under a stationary cursor, so the browser never fires mouseout — and
|
||||
// mouseover/mousemove during the pan animation would immediately re-set the
|
||||
// tooltip we just cleared (#1404).
|
||||
function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: boolean }; onMoveStart: () => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const start = () => { movingRef.current = true; onMoveStart() }
|
||||
const end = () => { movingRef.current = false }
|
||||
map.on('movestart zoomstart', start)
|
||||
map.on('moveend zoomend', end)
|
||||
return () => { map.off('movestart zoomstart', start); map.off('moveend zoomend', end) }
|
||||
}, [map, movingRef, onMoveStart])
|
||||
return null
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||
@@ -451,6 +469,8 @@ export const MapView = memo(function MapView({
|
||||
// day route, so hiding the route hides them too (#1065).
|
||||
return reservations.filter((r: Reservation) => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds, showTransitRoutes])
|
||||
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
||||
const transportRoutes = useTransportRoutes(visibleReservations)
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
const paddingOpts = useMemo((): L.FitBoundsOptions => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
@@ -465,9 +485,10 @@ export const MapView = memo(function MapView({
|
||||
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const mapMovingRef = useRef(false)
|
||||
|
||||
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
|
||||
if (hoverDisabled) return
|
||||
if (hoverDisabled || mapMovingRef.current) return
|
||||
setHoveredPlace(place)
|
||||
setTooltipPos({ x, y })
|
||||
}, [hoverDisabled])
|
||||
@@ -491,9 +512,18 @@ export const MapView = memo(function MapView({
|
||||
}, [hoveredPlace])
|
||||
|
||||
const handleMarkerClick = useCallback((id: number) => {
|
||||
// Clear the hover card right away: the recenter that follows moves the
|
||||
// marker out from under the cursor, so no mouseout will ever fire (#1404).
|
||||
setHoveredPlace(null)
|
||||
setTooltipPos(null)
|
||||
onMarkerClick?.(id)
|
||||
}, [onMarkerClick])
|
||||
|
||||
const clearHover = useCallback(() => {
|
||||
setHoveredPlace(null)
|
||||
setTooltipPos(null)
|
||||
}, [])
|
||||
|
||||
// photoUrls: only base64 thumbs for smooth map zoom
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
@@ -644,6 +674,7 @@ export const MapView = memo(function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
@@ -684,6 +715,7 @@ export const MapView = memo(function MapView({
|
||||
showConnections
|
||||
showStats={showReservationStats}
|
||||
onEndpointClick={onReservationClick}
|
||||
roadRoutes={transportRoutes}
|
||||
/>
|
||||
|
||||
{poiMarkers}
|
||||
|
||||
@@ -6,7 +6,10 @@ import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
// Stable fake map so fitBounds call counts survive re-renders.
|
||||
// Stable fake map so fitBounds call counts survive re-renders. The canvas
|
||||
// container is a single element so listeners registered by the component are
|
||||
// reachable from tests via dispatchEvent.
|
||||
const glCanvasContainer = vi.hoisted(() => document.createElement('div'))
|
||||
const glMap = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
@@ -25,10 +28,12 @@ const glMap = vi.hoisted(() => ({
|
||||
setLayoutProperty: vi.fn(),
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
getCanvasContainer: vi.fn(() => document.createElement('div')),
|
||||
getCanvasContainer: vi.fn(() => glCanvasContainer),
|
||||
getLayer: vi.fn().mockReturnValue(null),
|
||||
queryRenderedFeatures: vi.fn().mockReturnValue([]),
|
||||
querySourceFeatures: vi.fn().mockReturnValue([]),
|
||||
unproject: vi.fn(() => ({ lng: 2.3522, lat: 48.8566 })),
|
||||
getBounds: vi.fn(() => ({ getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 })),
|
||||
easeTo: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -265,4 +270,173 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-circle' }))
|
||||
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-count' }))
|
||||
})
|
||||
|
||||
function touchEvent(type: string, touches: Array<{ clientX: number; clientY: number }>) {
|
||||
const ev = new Event(type, { bubbles: true })
|
||||
Object.defineProperty(ev, 'touches', { value: touches })
|
||||
return ev
|
||||
}
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-006: touch long-press opens Add-Place at the held position (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-007: a moving finger (pan) cancels the long-press (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchmove', [{ clientX: 60, clientY: 90 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-008: a second finger (pinch) cancels the long-press (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }, { clientX: 80, clientY: 40 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-009: a plain right-click (map contextmenu event) opens Add-Place, deduped (#1398)', async () => {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void
|
||||
expect(handler).toBeTypeOf('function')
|
||||
act(() => {
|
||||
handler({ lngLat: { lat: 48.8566, lng: 2.3522 }, originalEvent: new MouseEvent('contextmenu') })
|
||||
// Android long-press fires the native contextmenu on top of our timer —
|
||||
// a second event inside the dedupe window must not open a second form.
|
||||
handler({ lngLat: { lat: 48.8566, lng: 2.3522 }, originalEvent: new MouseEvent('contextmenu') })
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 })
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-012: a right-button rotate/pitch drag does not open Add-Place on release (#1398)', async () => {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void
|
||||
act(() => {
|
||||
// mapbox-gl (unlike maplibre) still emits contextmenu after a right-drag
|
||||
// on Windows — the movement guard must drop it.
|
||||
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
|
||||
handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 140, clientY: 90 }) })
|
||||
})
|
||||
expect(onContext).not.toHaveBeenCalled()
|
||||
// ...while a stationary right-click still fires.
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
|
||||
handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 11, clientY: 10 }) })
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-013: a stale long-press suppression never swallows a later real tap (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} onMapClick={onMapClick} />)
|
||||
await act(async () => {})
|
||||
// Long-press fires (arms the suppression), but no click follows.
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
// The NEXT gesture starts fresh: its tap must reach the map click handler.
|
||||
const clickHandler = glMap.on.mock.calls.find(c => c[0] === 'click')?.[1] as (e: unknown) => void
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 80, clientY: 90 }]))
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchend', []))
|
||||
clickHandler({ lngLat: { lat: 3, lng: 4 }, originalEvent: { target: glCanvasContainer } })
|
||||
})
|
||||
expect(onMapClick).toHaveBeenCalledWith({ latlng: { lat: 3, lng: 4 } })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-010: middle-click still opens Add-Place (#1398 regression guard)', async () => {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 1, bubbles: true }))
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-011: clicking a marker clears the hover card; movestart clears + suppresses it (#1404)', async () => {
|
||||
// Markers are only reconciled once the style has loaded — fire 'load' like GL-005 does.
|
||||
glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => {
|
||||
if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)()
|
||||
return glMap
|
||||
})
|
||||
const mapboxgl = (await import('mapbox-gl')).default
|
||||
const places = [buildMapPlace({ id: 7, lat: 48.8584, lng: 2.2945, name: 'Tour Eiffel' })]
|
||||
const { queryByTestId } = render(<MapViewGL places={places} fitKey={1} onMarkerClick={vi.fn()} />)
|
||||
await act(async () => {})
|
||||
|
||||
const markerCall = (mapboxgl.Marker as unknown as ReturnType<typeof vi.fn>).mock.calls
|
||||
.find(c => c[0]?.element)
|
||||
expect(markerCall).toBeTruthy()
|
||||
const el = markerCall![0].element as HTMLElement
|
||||
|
||||
// hover shows the card
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeTruthy()
|
||||
|
||||
// click clears it (the flyTo that follows moves the marker away, no mouseleave will come)
|
||||
act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: false })) })
|
||||
expect(queryByTestId('tooltip')).toBeNull()
|
||||
|
||||
// hover again, then camera movement clears + suppresses
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeTruthy()
|
||||
const moveStart = glMap.on.mock.calls.find(c => c[0] === 'movestart')?.[1] as () => void
|
||||
const moveEnds = glMap.on.mock.calls.filter(c => c[0] === 'moveend').map(c => c[1] as () => void)
|
||||
act(() => { moveStart() })
|
||||
expect(queryByTestId('tooltip')).toBeNull()
|
||||
// while the camera is moving, a re-fired mouseenter must not bring it back
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeNull()
|
||||
// after the move ends, hover works again
|
||||
act(() => { moveEnds.forEach(fn => fn()) })
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
@@ -70,7 +71,7 @@ interface Props {
|
||||
onMarkerClick?: (id: number) => void
|
||||
hoverDisabled?: boolean
|
||||
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
|
||||
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
|
||||
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent | TouchEvent }) => void) | null
|
||||
center?: [number, number]
|
||||
zoom?: number
|
||||
fitKey?: number | null
|
||||
@@ -228,6 +229,10 @@ export function MapViewGL({
|
||||
const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null)
|
||||
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const hoverIdRef = useRef<number | null>(null)
|
||||
// True while the camera is moving (flyTo after a click, pan, zoom). Marker
|
||||
// elements get rebuilt during the move and re-fire mouseenter under a
|
||||
// stationary cursor, which would re-show the card we just cleared (#1404).
|
||||
const camMovingRef = useRef(false)
|
||||
|
||||
// Selecting a place rebuilds its marker element, so the browser never fires
|
||||
// mouseleave on the removed node and the fixed-position hover card gets
|
||||
@@ -448,7 +453,13 @@ export function MapViewGL({
|
||||
setMapReady(true)
|
||||
})
|
||||
|
||||
// Set by the long-press handler below: the touchend tap that follows a
|
||||
// long-press must not count as a normal map click (#1398).
|
||||
let suppressNextClick = false
|
||||
map.on('click', (e) => {
|
||||
// The tap that ends a long-press would otherwise land here and clear
|
||||
// the selection right after the Add-Place form opened (#1398).
|
||||
if (suppressNextClick) { suppressNextClick = false; return }
|
||||
const t = e.originalEvent.target as HTMLElement
|
||||
if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click
|
||||
// A click that lands on a cluster bubble is the cluster's to handle
|
||||
@@ -469,19 +480,52 @@ export function MapViewGL({
|
||||
}
|
||||
map.on('moveend', emitViewport)
|
||||
map.once('idle', emitViewport)
|
||||
// In the GL map the right mouse button is reserved for the
|
||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||
// to the middle mouse button (button === 1) instead.
|
||||
// Clear the hover card (and the anchored POI popup) as soon as the camera
|
||||
// starts moving, and keep hover suppressed until it stops: the marker
|
||||
// slides away under a stationary cursor, so mouseleave never fires (#1404).
|
||||
const onCamStart = () => {
|
||||
camMovingRef.current = true
|
||||
hoverIdRef.current = null
|
||||
setHoverPlace(null)
|
||||
setHoverPos(null)
|
||||
popupRef.current?.remove()
|
||||
}
|
||||
const onCamEnd = () => { camMovingRef.current = false }
|
||||
map.on('movestart', onCamStart)
|
||||
map.on('moveend', onCamEnd)
|
||||
// "Add place here" on the GL map (#1398). Three routes into one handler:
|
||||
// middle-click (the original binding), a plain right-click via the map's
|
||||
// own contextmenu event — both GL libs suppress that event while the
|
||||
// right-button rotate/pitch drag is active, so it can't fight the gesture,
|
||||
// and it also covers Mac ctrl-click / two-finger tap — and a touch
|
||||
// long-press, which neither GL lib synthesizes into contextmenu (Leaflet
|
||||
// does, which is why the OSM map already worked on mobile).
|
||||
const canvas = map.getCanvasContainer()
|
||||
let lastContextFire = 0
|
||||
const fireContext = (lngLat: { lat: number; lng: number }, originalEvent: MouseEvent | TouchEvent): boolean => {
|
||||
// Android fires a native contextmenu for a long-press on top of our own
|
||||
// timer — dedupe so the form doesn't open twice.
|
||||
if (Date.now() - lastContextFire < 700) return false
|
||||
lastContextFire = Date.now()
|
||||
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
|
||||
return true
|
||||
}
|
||||
// MapLibre swallows the map contextmenu at the end of a right-button
|
||||
// rotate/pitch drag, but mapbox-gl does NOT — and on Windows the DOM
|
||||
// contextmenu arrives after mouseup, so every rotate would end by opening
|
||||
// the Add-Place form. Track the right-button press position and drop a
|
||||
// contextmenu whose pointer travelled like a drag rather than a click.
|
||||
let rightDownAt: { x: number; y: number } | null = null
|
||||
const onAuxDown = (ev: MouseEvent) => {
|
||||
if (ev.button === 2) {
|
||||
rightDownAt = { x: ev.clientX, y: ev.clientY }
|
||||
return
|
||||
}
|
||||
if (ev.button !== 1) return
|
||||
ev.preventDefault()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
|
||||
onClickRefs.current.context?.({
|
||||
latlng: { lat: lngLat.lat, lng: lngLat.lng },
|
||||
originalEvent: ev,
|
||||
})
|
||||
fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)
|
||||
}
|
||||
// Also suppress the browser's native auxclick menu on middle-click.
|
||||
const onAuxClick = (ev: MouseEvent) => {
|
||||
@@ -489,6 +533,49 @@ export function MapViewGL({
|
||||
}
|
||||
canvas.addEventListener('mousedown', onAuxDown)
|
||||
canvas.addEventListener('auxclick', onAuxClick)
|
||||
map.on('contextmenu', (e: { lngLat: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
const down = rightDownAt
|
||||
rightDownAt = null
|
||||
if (down && Math.hypot(e.originalEvent.clientX - down.x, e.originalEvent.clientY - down.y) > 5) return
|
||||
fireContext(e.lngLat, e.originalEvent)
|
||||
})
|
||||
// Touch long-press: 600 ms hold (Leaflet's tapHold feel) with a 10 px
|
||||
// move tolerance so slow pans and pinches don't open the form.
|
||||
let lpTimer: number | null = null
|
||||
let lpStart: { x: number; y: number } | null = null
|
||||
const cancelLongPress = () => {
|
||||
if (lpTimer !== null) window.clearTimeout(lpTimer)
|
||||
lpTimer = null
|
||||
lpStart = null
|
||||
}
|
||||
const onTouchStart = (ev: TouchEvent) => {
|
||||
// A fresh gesture clears a stale suppression flag: not every long-press
|
||||
// is followed by a click (finger drag after the hold, Android's native
|
||||
// contextmenu path), and the flag must never swallow a later real tap.
|
||||
suppressNextClick = false
|
||||
if (ev.touches.length !== 1) { cancelLongPress(); return }
|
||||
if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return
|
||||
const t = ev.touches[0]
|
||||
lpStart = { x: t.clientX, y: t.clientY }
|
||||
lpTimer = window.setTimeout(() => {
|
||||
lpTimer = null
|
||||
if (!lpStart) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const lngLat = map.unproject([lpStart.x - rect.left, lpStart.y - rect.top])
|
||||
lpStart = null
|
||||
// Only suppress the tap when OUR fire opened the form — if the native
|
||||
// contextmenu beat us to it (dedupe), no click needs swallowing.
|
||||
if (fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)) suppressNextClick = true
|
||||
}, 600)
|
||||
}
|
||||
const onTouchMove = (ev: TouchEvent) => {
|
||||
const t = ev.touches[0]
|
||||
if (lpStart && (!t || Math.hypot(t.clientX - lpStart.x, t.clientY - lpStart.y) > 10)) cancelLongPress()
|
||||
}
|
||||
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
|
||||
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||
canvas.addEventListener('touchend', cancelLongPress)
|
||||
canvas.addEventListener('touchcancel', cancelLongPress)
|
||||
|
||||
// Drop follow mode if the user pans the map manually — matches the
|
||||
// Apple Maps behaviour where the blue dot stays but the map no longer
|
||||
@@ -537,6 +624,11 @@ export function MapViewGL({
|
||||
return () => {
|
||||
canvas.removeEventListener('mousedown', onAuxDown)
|
||||
canvas.removeEventListener('auxclick', onAuxClick)
|
||||
canvas.removeEventListener('touchstart', onTouchStart)
|
||||
canvas.removeEventListener('touchmove', onTouchMove)
|
||||
canvas.removeEventListener('touchend', cancelLongPress)
|
||||
canvas.removeEventListener('touchcancel', cancelLongPress)
|
||||
cancelLongPress()
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
|
||||
@@ -652,16 +744,21 @@ export function MapViewGL({
|
||||
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
// Clear the card right away — the flyTo that follows moves the marker
|
||||
// out from under the cursor and mouseleave never fires (#1404).
|
||||
hoverIdRef.current = null
|
||||
setHoverPlace(null)
|
||||
setHoverPos(null)
|
||||
onClickRefs.current.marker?.(place.id)
|
||||
})
|
||||
el.addEventListener('mouseenter', (ev) => {
|
||||
if (hoverDisabledRef.current) return
|
||||
if (hoverDisabledRef.current || camMovingRef.current) return
|
||||
hoverIdRef.current = place.id
|
||||
setHoverPlace(place as Place & { category_color?: string; category_icon?: string; category_name?: string })
|
||||
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
|
||||
})
|
||||
el.addEventListener('mousemove', (ev) => {
|
||||
if (hoverDisabledRef.current) return
|
||||
if (hoverDisabledRef.current || camMovingRef.current) return
|
||||
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
|
||||
})
|
||||
el.addEventListener('mouseleave', () => {
|
||||
@@ -807,6 +904,8 @@ export function MapViewGL({
|
||||
// day route, so hiding the route hides them too (#1065).
|
||||
return reservations.filter(r => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds, showTransitRoutes])
|
||||
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
||||
const transportRoutes = useTransportRoutes(visibleReservations)
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
@@ -824,8 +923,8 @@ export function MapViewGL({
|
||||
showStats: showReservationStats,
|
||||
showEndpointLabels,
|
||||
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
||||
})
|
||||
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider])
|
||||
}, transportRoutes)
|
||||
}, [visibleReservations, transportRoutes, showReservationStats, showEndpointLabels, mapReady, glProvider])
|
||||
|
||||
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
||||
const paddingOpts = useMemo(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import L from 'leaflet'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry'
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -64,41 +65,6 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||
}
|
||||
|
||||
function toRad(d: number) { return d * Math.PI / 180 }
|
||||
function toDeg(r: number) { return r * 180 / Math.PI }
|
||||
|
||||
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
||||
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
||||
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
||||
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||
if (d === 0) return [a, b]
|
||||
const pts: [number, number][] = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = i / steps
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||
const B = Math.sin(f * d) / Math.sin(d)
|
||||
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
||||
const lng = Math.atan2(y, x)
|
||||
pts.push([toDeg(lat), toDeg(lng)])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
function splitAntimeridian(points: [number, number][]): [number, number][][] {
|
||||
const segments: [number, number][][] = []
|
||||
let cur: [number, number][] = []
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
cur = []
|
||||
}
|
||||
cur.push(points[i])
|
||||
}
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
return segments
|
||||
}
|
||||
|
||||
function cleanName(name: string): string {
|
||||
return name.replace(/\s*\([^)]*\)/g, '').trim()
|
||||
@@ -342,9 +308,12 @@ interface Props {
|
||||
showConnections: boolean
|
||||
showStats: boolean
|
||||
onEndpointClick?: (reservationId: number) => void
|
||||
// Real road-network geometry for car/bus/taxi/bicycle bookings, keyed by
|
||||
// reservation id. When present it is drawn instead of the straight arc.
|
||||
roadRoutes?: Map<number, [number, number][]>
|
||||
}
|
||||
|
||||
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick }: Props) {
|
||||
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick, roadRoutes }: Props) {
|
||||
useEndpointPane()
|
||||
const map = useMap()
|
||||
const [zoom, setZoom] = useState(() => map.getZoom())
|
||||
@@ -375,7 +344,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
const a = waypoints[i]
|
||||
const b = waypoints[i + 1]
|
||||
const segArcs = isGeo
|
||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], true)
|
||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||
arcs.push(...segArcs)
|
||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||
@@ -424,35 +393,41 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleItems.map(item => item.transitSegs.length > 0
|
||||
? item.transitSegs.map((seg, segIdx) => (
|
||||
<Fragment key={`transit-${item.res.id}-${segIdx}`}>
|
||||
{!seg.walk && (
|
||||
{visibleItems.map(item => {
|
||||
if (item.transitSegs.length > 0) {
|
||||
return item.transitSegs.map((seg, segIdx) => (
|
||||
<Fragment key={`transit-${item.res.id}-${segIdx}`}>
|
||||
{!seg.walk && (
|
||||
<Polyline
|
||||
positions={seg.coords}
|
||||
pathOptions={{ color: '#ffffff', weight: 6, opacity: 0.85, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>
|
||||
)}
|
||||
<Polyline
|
||||
positions={seg.coords}
|
||||
pathOptions={{ color: '#ffffff', weight: 6, opacity: 0.85, lineCap: 'round', lineJoin: 'round' }}
|
||||
pathOptions={seg.walk
|
||||
? { color: '#64748b', weight: 3, opacity: 0.8, dashArray: '1, 7', lineCap: 'round' }
|
||||
: { color: seg.color || TYPE_META.transit.color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>
|
||||
)}
|
||||
<Polyline
|
||||
positions={seg.coords}
|
||||
pathOptions={seg.walk
|
||||
? { color: '#64748b', weight: 3, opacity: 0.8, dashArray: '1, 7', lineCap: 'round' }
|
||||
: { color: seg.color || TYPE_META.transit.color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>
|
||||
</Fragment>
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
pathOptions={{
|
||||
color: TYPE_META[item.type].color,
|
||||
weight: 2.5,
|
||||
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
|
||||
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: item.arcs.map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
pathOptions={{
|
||||
color: TYPE_META[item.type].color,
|
||||
weight: 2.5,
|
||||
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
|
||||
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
|
||||
}}
|
||||
/>
|
||||
)))}
|
||||
})}
|
||||
|
||||
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => (
|
||||
<Marker
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { greatCircle, unwrapLngs, geodesicArcs } from './flightGeodesy'
|
||||
|
||||
const YVR: [number, number] = [49.19, -123.18]
|
||||
const ICN: [number, number] = [37.46, 126.44]
|
||||
const FRA: [number, number] = [50.03, 8.57]
|
||||
const JFK: [number, number] = [40.64, -73.78]
|
||||
|
||||
const maxConsecutiveDeltaLng = (pts: [number, number][]) =>
|
||||
pts.reduce((max, p, i) => (i === 0 ? 0 : Math.max(max, Math.abs(p[1] - pts[i - 1][1]))), 0)
|
||||
|
||||
describe('flightGeodesy (#1411)', () => {
|
||||
it('a date-line crossing unwraps into one continuous arc plus a shifted copy for Leaflet', () => {
|
||||
const arcs = geodesicArcs(YVR, ICN, true)
|
||||
expect(arcs).toHaveLength(2)
|
||||
const [base, shifted] = arcs
|
||||
// continuous: no ±360 jump anywhere
|
||||
expect(maxConsecutiveDeltaLng(base)).toBeLessThan(180)
|
||||
// endpoints: starts at YVR, ends at ICN unwrapped westwards (126.44 - 360)
|
||||
expect(base[0][0]).toBeCloseTo(YVR[0], 5)
|
||||
expect(base[0][1]).toBeCloseTo(YVR[1], 5)
|
||||
expect(base[base.length - 1][1]).toBeCloseTo(ICN[1] - 360, 5)
|
||||
// the copy is the base shifted by exactly +360
|
||||
expect(shifted).toHaveLength(base.length)
|
||||
shifted.forEach(([lat, lng], i) => {
|
||||
expect(lat).toBe(base[i][0])
|
||||
expect(lng).toBeCloseTo(base[i][1] + 360, 10)
|
||||
})
|
||||
})
|
||||
|
||||
it('an eastbound crossing unwraps upwards and shifts by -360', () => {
|
||||
const arcs = geodesicArcs(ICN, YVR, true)
|
||||
expect(arcs).toHaveLength(2)
|
||||
const [base, shifted] = arcs
|
||||
expect(maxConsecutiveDeltaLng(base)).toBeLessThan(180)
|
||||
expect(base[base.length - 1][1]).toBeCloseTo(YVR[1] + 360, 5)
|
||||
expect(shifted[0][1]).toBeCloseTo(base[0][1] - 360, 10)
|
||||
})
|
||||
|
||||
it('a non-crossing flight stays a single in-range arc', () => {
|
||||
const arcs = geodesicArcs(FRA, JFK, true)
|
||||
expect(arcs).toHaveLength(1)
|
||||
for (const [, lng] of arcs[0]) {
|
||||
expect(lng).toBeGreaterThanOrEqual(-180)
|
||||
expect(lng).toBeLessThanOrEqual(180)
|
||||
}
|
||||
})
|
||||
|
||||
it('GL mode always returns exactly one continuous arc (world copies handle the wrap)', () => {
|
||||
const arcs = geodesicArcs(YVR, ICN, false)
|
||||
expect(arcs).toHaveLength(1)
|
||||
expect(maxConsecutiveDeltaLng(arcs[0])).toBeLessThan(180)
|
||||
})
|
||||
|
||||
it('a zero-distance leg passes the endpoints through', () => {
|
||||
expect(greatCircle(YVR, YVR)).toEqual([YVR, YVR])
|
||||
})
|
||||
|
||||
it('unwrapLngs keeps already-continuous input unchanged', () => {
|
||||
const pts: [number, number][] = [[0, 170], [0, 175], [0, 179]]
|
||||
expect(unwrapLngs(pts)).toEqual(pts)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// Great-circle geometry for transport routes (flights, cruises, ferries),
|
||||
// shared by the Leaflet and Mapbox/MapLibre renderers (#1411).
|
||||
|
||||
const toRad = (d: number) => d * Math.PI / 180
|
||||
const toDeg = (r: number) => r * 180 / Math.PI
|
||||
|
||||
export function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
||||
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
||||
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
||||
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||
if (d === 0) return [a, b]
|
||||
const pts: [number, number][] = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = i / steps
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||
const B = Math.sin(f * d) / Math.sin(d)
|
||||
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
||||
const lng = Math.atan2(y, x)
|
||||
pts.push([toDeg(lat), toDeg(lng)])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the longitudes of a sampled arc continuous: atan2 normalizes every
|
||||
* sample to [-180, 180], so a date-line crossing shows up as a ±360 jump
|
||||
* between neighbours — which the renderers draw as a line across the whole
|
||||
* map. Carrying a running offset keeps each Δlng under 180°, at the cost of
|
||||
* longitudes leaving the [-180, 180] range (both map libraries project those
|
||||
* linearly, which is exactly what makes the wrap seamless).
|
||||
*/
|
||||
export function unwrapLngs(points: [number, number][]): [number, number][] {
|
||||
let offset = 0
|
||||
return points.map(([lat, lng], i) => {
|
||||
if (i === 0) return [lat, lng] as [number, number]
|
||||
const prev = points[i - 1][1] + offset
|
||||
let cur = lng + offset
|
||||
if (cur - prev > 180) { offset -= 360; cur -= 360 }
|
||||
else if (cur - prev < -180) { offset += 360; cur += 360 }
|
||||
return [lat, cur] as [number, number]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The polylines to draw for one leg. The base arc is continuous (unwrapped),
|
||||
* so panning across the antimeridian shows one unbroken line. With
|
||||
* `wrapCopies` (Leaflet — its vector layers don't repeat across world
|
||||
* copies), a ±360-shifted duplicate keeps both halves visible in the
|
||||
* standard [-180, 180] view; GL maps repeat features themselves
|
||||
* (renderWorldCopies), so the duplicate would just double the line opacity.
|
||||
*/
|
||||
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean): [number, number][][] {
|
||||
const arc = unwrapLngs(greatCircle(a, b))
|
||||
const crosses = arc.some(([, lng]) => lng < -180 || lng > 180)
|
||||
if (!crosses || !wrapCopies) return [arc]
|
||||
const shift = arc.some(([, lng]) => lng < -180) ? 360 : -360
|
||||
return [arc, arc.map(([lat, lng]) => [lat, lng + shift] as [number, number])]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
// A minimal mapbox-gl stand-in: a persistent source that records the last
|
||||
// setData, and project() spreading points far enough apart to pass the
|
||||
// per-type pixel-distance visibility filter.
|
||||
function fakeMap() {
|
||||
const source = { setData: vi.fn() }
|
||||
return {
|
||||
_source: source,
|
||||
getSource: () => source,
|
||||
addSource: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
getLayer: () => undefined,
|
||||
removeLayer: vi.fn(),
|
||||
removeSource: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
getZoom: () => 12,
|
||||
project: ([lng, lat]: [number, number]) => ({ x: lng * 1000, y: lat * 1000 }),
|
||||
}
|
||||
}
|
||||
|
||||
const FakeMarker = vi.fn(function () {
|
||||
const marker = {
|
||||
setLngLat: () => marker,
|
||||
addTo: () => marker,
|
||||
remove: vi.fn(),
|
||||
getElement: () => document.createElement('div'),
|
||||
}
|
||||
return marker
|
||||
}) as unknown as new () => unknown
|
||||
|
||||
function carBooking(): Reservation {
|
||||
return {
|
||||
id: 1, type: 'car', status: 'confirmed',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 48.0, lng: 2.0, timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 48.2, lng: 2.3, timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
}
|
||||
|
||||
const opts = { showConnections: true, showStats: false, showEndpointLabels: false }
|
||||
|
||||
function lastFeatureCoords(map: ReturnType<typeof fakeMap>) {
|
||||
const calls = map._source.setData.mock.calls
|
||||
const data = calls[calls.length - 1]?.[0] as { features: { geometry: { coordinates: [number, number][] } }[] }
|
||||
return data.features[0].geometry.coordinates
|
||||
}
|
||||
|
||||
describe('ReservationMapboxOverlay road routes (#1425)', () => {
|
||||
it('draws the real road geometry when a road route is supplied', () => {
|
||||
const map = fakeMap()
|
||||
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
|
||||
const road: [number, number][] = [[48.0, 2.0], [48.1, 2.15], [48.2, 2.3]]
|
||||
overlay.update([carBooking()], opts, new Map([[1, road]]))
|
||||
// GeoJSON is [lng, lat]; the routed 3-point line, not the straight 2-point arc.
|
||||
expect(lastFeatureCoords(map)).toEqual([[2.0, 48.0], [2.15, 48.1], [2.3, 48.2]])
|
||||
})
|
||||
|
||||
it('falls back to the straight arc when no road route is supplied', () => {
|
||||
const map = fakeMap()
|
||||
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
|
||||
overlay.update([carBooking()], opts)
|
||||
expect(lastFeatureCoords(map)).toEqual([[2.0, 48.0], [2.3, 48.2]])
|
||||
})
|
||||
|
||||
it('sets no line features while connections are hidden', () => {
|
||||
const map = fakeMap()
|
||||
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
|
||||
overlay.update([carBooking()], { ...opts, showConnections: false }, new Map([[1, [[48, 2], [48.2, 2.3]]]]))
|
||||
const calls = map._source.setData.mock.calls
|
||||
const data = calls[calls.length - 1]?.[0] as { features: unknown[] }
|
||||
expect(data.features).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
|
||||
import { getTransitMapSegments } from './transitGeometry'
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -34,43 +35,8 @@ const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }
|
||||
transport_other: { icon: Route, geodesic: false },
|
||||
}
|
||||
|
||||
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
|
||||
// ── geometry helpers (shared with ReservationOverlay via flightGeodesy) ──
|
||||
const toRad = (d: number) => d * Math.PI / 180
|
||||
const toDeg = (r: number) => r * 180 / Math.PI
|
||||
|
||||
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
||||
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
||||
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
||||
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||
if (d === 0) return [a, b]
|
||||
const pts: [number, number][] = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = i / steps
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||
const B = Math.sin(f * d) / Math.sin(d)
|
||||
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
||||
const lng = Math.atan2(y, x)
|
||||
pts.push([toDeg(lat), toDeg(lng)])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
function splitAntimeridian(points: [number, number][]): [number, number][][] {
|
||||
const segments: [number, number][][] = []
|
||||
let cur: [number, number][] = []
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
cur = []
|
||||
}
|
||||
cur.push(points[i])
|
||||
}
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
return segments
|
||||
}
|
||||
|
||||
function haversineKm(a: [number, number], b: [number, number]): number {
|
||||
const R = 6371
|
||||
@@ -157,7 +123,10 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
||||
const a = waypoints[i]
|
||||
const b = waypoints[i + 1]
|
||||
const segArcs = isGeo
|
||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||
// GL maps repeat features across world copies themselves, so one
|
||||
// continuous unwrapped arc is enough (a shifted duplicate would
|
||||
// coincide with the wrapped copy and double the line opacity).
|
||||
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], false)
|
||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||
arcs.push(...segArcs)
|
||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||
@@ -234,6 +203,7 @@ type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string
|
||||
export class ReservationMapboxOverlay {
|
||||
private map: mapboxgl.Map
|
||||
private items: TransportItem[] = []
|
||||
private roadRoutes: Map<number, [number, number][]> = new Map()
|
||||
private opts: ReservationOverlayOptions
|
||||
private MarkerCtor: MarkerConstructor
|
||||
private endpointMarkers: GlMarker[] = []
|
||||
@@ -252,9 +222,10 @@ export class ReservationMapboxOverlay {
|
||||
map.on('render', this.updateStatsRotation)
|
||||
}
|
||||
|
||||
update(reservations: Reservation[], opts: ReservationOverlayOptions) {
|
||||
update(reservations: Reservation[], opts: ReservationOverlayOptions, roadRoutes?: Map<number, [number, number][]>) {
|
||||
this.opts = opts
|
||||
this.items = buildItems(reservations)
|
||||
this.roadRoutes = roadRoutes ?? new Map()
|
||||
this.render()
|
||||
}
|
||||
|
||||
@@ -357,7 +328,10 @@ export class ReservationMapboxOverlay {
|
||||
},
|
||||
}))
|
||||
}
|
||||
return item.arcs.map(seg => ({
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = this.roadRoutes.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
resId: item.res.id,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
import { getFlightLegs } from '../../utils/flightLegs'
|
||||
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
@@ -243,7 +243,21 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') {
|
||||
const legs = getTrainLegs(r)
|
||||
if (legs.length > 1) {
|
||||
// Multi-leg: one line per leg so every train number + segment route shows.
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
|
||||
.filter(Boolean).join(' · '))
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : ''
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
|
||||
@@ -1953,8 +1953,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
if (res.__leg) {
|
||||
// One leg of a multi-leg flight — show this segment's own route.
|
||||
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
// One leg of a multi-leg flight/train — show this segment's own detail.
|
||||
const parts = res.type === 'train'
|
||||
? [res.__leg.train_number, res.__leg.platform ? `Gl. ${res.__leg.platform}` : '', res.__leg.seat ? `Sitz ${res.__leg.seat}` : ''].filter(Boolean)
|
||||
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
if (res.__leg.from || res.__leg.to)
|
||||
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(' · ')
|
||||
|
||||
@@ -390,4 +390,68 @@ describe('TransportModal', () => {
|
||||
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.queryByRole('button', { name: 'Automated transport' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Multi-leg trains (#1150) ───────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-025: a train with an added stop saves from/stop/to endpoints + metadata.legs', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Berlin → München');
|
||||
// Insert an intermediate station (2 → 3 stations = 2 legs).
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
const stations = screen.getAllByTestId('location-select');
|
||||
expect(stations).toHaveLength(3);
|
||||
fireEvent.change(stations[0], { target: { value: 'Berlin Hbf' } });
|
||||
fireEvent.change(stations[1], { target: { value: 'Frankfurt Hbf' } });
|
||||
fireEvent.change(stations[2], { target: { value: 'München Hbf' } });
|
||||
// Per-leg train number on the first station (placeholder ICE 123).
|
||||
const trainNumbers = screen.getAllByPlaceholderText('ICE 123');
|
||||
fireEvent.change(trainNumbers[0], { target: { value: 'ICE 100' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.type).toBe('train');
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'to']);
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin Hbf', 'Frankfurt Hbf', 'München Hbf']);
|
||||
expect(payload.metadata.legs).toHaveLength(2);
|
||||
expect(payload.metadata.legs[0]).toMatchObject({ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100' });
|
||||
expect(payload.metadata.train_number).toBe('ICE 100'); // flat mirror of leg 0
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-027: a train with a day + train number but no geocoded station still saves them (#1150 regression)', async () => {
|
||||
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2026-08-01', title: 'Day 1' }] as any;
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 599');
|
||||
// Fill the train number + a departure time, but never pick a geocoded station.
|
||||
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'ICE 599' } });
|
||||
fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '08:00' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
// The day, time and train number survive even without any map-picked station.
|
||||
expect(payload.day_id).toBe(10);
|
||||
expect(payload.reservation_time).toBe('2026-08-01T08:00');
|
||||
expect(payload.metadata.train_number).toBe('ICE 599');
|
||||
expect(payload.endpoints).toEqual([]); // no geocoded station → no map endpoints, like before
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-026: a two-station train saves flat (no metadata.legs)', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Köln → Aachen');
|
||||
const stations = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } });
|
||||
fireEvent.change(stations[1], { target: { value: 'Aachen Hbf' } });
|
||||
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'RE 9' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
expect(payload.metadata.legs).toBeUndefined();
|
||||
expect(payload.metadata.train_number).toBe('RE 9');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence:
|
||||
}
|
||||
}
|
||||
|
||||
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return {
|
||||
role, sequence,
|
||||
name: l.name,
|
||||
@@ -88,6 +88,24 @@ function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
|
||||
}
|
||||
|
||||
// ── Multi-leg train stations ───────────────────────────────────────────────
|
||||
// A train mirrors the flight route model, but its waypoints are STATIONS
|
||||
// (location search, no timezone) and each leg carries a train number + platform
|
||||
// instead of an airline + flight number. N stations = N-1 legs.
|
||||
interface StationWaypointForm {
|
||||
location: LocationPoint | null
|
||||
arrDayId: string | number
|
||||
arrTime: string
|
||||
depDayId: string | number
|
||||
depTime: string
|
||||
train_number: string
|
||||
platform: string
|
||||
seat: string
|
||||
}
|
||||
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
|
||||
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
@@ -159,6 +177,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
||||
// Train route as an ordered list of stations (origin .. stops .. destination).
|
||||
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
@@ -237,6 +257,46 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
wps = [dep, arr]
|
||||
}
|
||||
setWaypoints(wps)
|
||||
} else if (type === 'train') {
|
||||
// Mirror the flight seeding with stations + per-leg train fields. A
|
||||
// current single-leg train (2 endpoints, no metadata.legs) round-trips
|
||||
// through the >=2 branch: the flat train_number/platform/seat land on
|
||||
// the first station, dep/arr day+time from src.day_id/end_day_id.
|
||||
const orderedEps = orderedEndpoints(src)
|
||||
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
|
||||
let wps: StationWaypointForm[]
|
||||
if (orderedEps.length >= 2) {
|
||||
wps = orderedEps.map((ep, i) => {
|
||||
const legInto = metaLegs[i - 1]
|
||||
const legOut = metaLegs[i]
|
||||
const isFirst = i === 0
|
||||
const isLast = i === orderedEps.length - 1
|
||||
return {
|
||||
location: locationFromEndpoint(ep),
|
||||
arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''),
|
||||
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
|
||||
depDayId: legOut?.dep_day_id ?? (isFirst ? (src.day_id ?? '') : ''),
|
||||
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
||||
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
|
||||
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
|
||||
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const dep = emptyStationWaypoint(src.day_id ?? '')
|
||||
dep.location = locationFromEndpoint(from)
|
||||
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
|
||||
dep.train_number = meta.train_number ?? ''
|
||||
dep.platform = meta.platform ?? ''
|
||||
dep.seat = meta.seat ?? ''
|
||||
const arr = emptyStationWaypoint(src.end_day_id ?? src.day_id ?? '')
|
||||
arr.location = locationFromEndpoint(to)
|
||||
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
|
||||
wps = [dep, arr]
|
||||
}
|
||||
setTrainWaypoints(wps)
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
} else {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
@@ -247,6 +307,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
|
||||
}
|
||||
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
|
||||
|
||||
@@ -270,6 +331,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
|
||||
const firstWp = flightWps[0]
|
||||
const lastWp = flightWps[flightWps.length - 1]
|
||||
// Train route: the first/last waypoint drive the span + flat metadata
|
||||
// (day/time/train number are entered independently of geocoding, exactly
|
||||
// like the old form fields, so a train with no map-picked station still
|
||||
// saves its day/time/train number). Only geocoded stations become map
|
||||
// endpoints + legs, mirroring the old "push only if the location is set".
|
||||
const trainWps = form.type === 'train' ? trainWaypoints : []
|
||||
const firstTrainWp = trainWps[0]
|
||||
const lastTrainWp = trainWps[trainWps.length - 1]
|
||||
const trainStations = form.type === 'train' ? trainWaypoints.filter(w => w.location) : []
|
||||
// Per-leg day-plan positions are owned by the day planner, not this form — keep
|
||||
// them when re-saving so editing a flight doesn't reset where its legs sit.
|
||||
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
|
||||
@@ -308,9 +378,31 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
}
|
||||
if (firstWp?.seat) metadata.seat = firstWp.seat
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
// Flat keys mirror the first leg so legacy readers keep working; a
|
||||
// 2-station train emits exactly {train_number?,platform?,seat?} — the
|
||||
// same shape it saved before this feature.
|
||||
if (firstTrainWp?.train_number) metadata.train_number = firstTrainWp.train_number
|
||||
if (firstTrainWp?.platform) metadata.platform = firstTrainWp.platform
|
||||
if (firstTrainWp?.seat) metadata.seat = firstTrainWp.seat
|
||||
// Per-leg detail only for a true multi-leg train (>2 geocoded stations);
|
||||
// a simple train keeps the same flat metadata it saved before.
|
||||
if (trainStations.length > 2) {
|
||||
metadata.legs = trainStations.slice(0, -1).map((w, i) => {
|
||||
const next = trainStations[i + 1]
|
||||
return {
|
||||
from: w.location!.name,
|
||||
to: next.location!.name,
|
||||
...(w.train_number ? { train_number: w.train_number } : {}),
|
||||
...(w.platform ? { platform: w.platform } : {}),
|
||||
...(w.seat ? { seat: w.seat } : {}),
|
||||
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
||||
dep_time: w.depTime || null,
|
||||
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
||||
arr_time: next.arrTime || null,
|
||||
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A transit itinerary (#1065) lives in metadata.transit + 'stop' endpoints,
|
||||
@@ -340,6 +432,18 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const time = isLast ? w.arrTime : w.depTime
|
||||
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
|
||||
})
|
||||
} else if (form.type === 'train') {
|
||||
trainStations.forEach((w, i) => {
|
||||
const isFirst = i === 0
|
||||
const isLast = i === trainStations.length - 1
|
||||
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
|
||||
const dId = isLast ? w.arrDayId : w.depDayId
|
||||
const time = isLast ? w.arrTime : w.depTime
|
||||
// The destination date falls back to the departure day (as the old flat
|
||||
// path did via `endDay ?? startDay`) when the arrival day is left blank.
|
||||
const date = dayDate(dId) ?? (isLast ? dayDate(firstTrainWp?.depDayId ?? '') : null)
|
||||
endpoints.push(endpointFromLocation(w.location!, role, i, date, time || null))
|
||||
})
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||
// Keep the itinerary's transfer stops while the route is unchanged (#1065).
|
||||
@@ -354,22 +458,30 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
|
||||
}
|
||||
|
||||
// Flights derive their span from the first/last waypoint; other transports
|
||||
// keep using the single departure/arrival form fields unchanged.
|
||||
// Flights and trains derive their span from the first/last waypoint; other
|
||||
// transports keep using the single departure/arrival form fields unchanged.
|
||||
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
|
||||
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
|
||||
const trainDepDay = firstTrainWp && firstTrainWp.depDayId ? Number(firstTrainWp.depDayId) : null
|
||||
const trainArrDay = lastTrainWp && lastTrainWp.arrDayId ? Number(lastTrainWp.arrDayId) : null
|
||||
const payload = {
|
||||
title: form.title,
|
||||
type: form.type,
|
||||
status: form.status,
|
||||
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
||||
end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
||||
day_id: form.type === 'flight' ? flightDepDay : form.type === 'train' ? trainDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
||||
end_day_id: form.type === 'flight' ? flightArrDay : form.type === 'train' ? trainArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
||||
reservation_time: form.type === 'flight'
|
||||
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
|
||||
: buildTime(startDay, form.departure_time),
|
||||
: form.type === 'train'
|
||||
? buildTime(days.find(d => d.id === trainDepDay), firstTrainWp?.depTime || '')
|
||||
: buildTime(startDay, form.departure_time),
|
||||
reservation_end_time: form.type === 'flight'
|
||||
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
|
||||
: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
: form.type === 'train'
|
||||
// Fall back to the departure day so a same-day train (arrival day left
|
||||
// blank) still gets its date, matching the non-flight `endDay ?? startDay`.
|
||||
? buildTime(days.find(d => d.id === trainArrDay) ?? days.find(d => d.id === trainDepDay), lastTrainWp?.arrTime || '')
|
||||
: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
@@ -653,6 +765,80 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : form.type === 'train' ? (
|
||||
/* ── Train route: ordered stations (origin · stops · destination) ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label className={labelClass}>{t('reservations.layover.route')}</label>
|
||||
{trainWaypoints.map((wp, i) => {
|
||||
const isFirst = i === 0
|
||||
const isLast = i === trainWaypoints.length - 1
|
||||
const updateWp = (patch: Partial<StationWaypointForm>) => setTrainWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
|
||||
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} />
|
||||
</div>
|
||||
{!isFirst && !isLast && (
|
||||
<button type="button" onClick={() => setTrainWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isFirst && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
|
||||
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
|
||||
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLast && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.departureDate')}</label>
|
||||
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.departureTime')}</label>
|
||||
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
|
||||
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.platform')}</label>
|
||||
<input type="text" value={wp.platform} onChange={e => updateWp({ platform: e.target.value })} placeholder="12" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<button type="button" onClick={() => setTrainWaypoints(prev => [...prev.slice(0, i + 1), emptyStationWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
|
||||
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={12} /> {t('reservations.layover.addStop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* From / To endpoints (non-flight) */}
|
||||
@@ -694,25 +880,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
)}
|
||||
|
||||
{/* Train-specific fields */}
|
||||
{form.type === 'train' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
|
||||
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
|
||||
placeholder="ICE 123" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.platform')}</label>
|
||||
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
|
||||
placeholder="12" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
|
||||
placeholder="42A" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Train number / platform / seat are per-leg now (in the route above). */}
|
||||
|
||||
{/* Booking Code + Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Reservation, ReservationEndpoint } from '../types'
|
||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||
|
||||
/**
|
||||
* Real road-network geometry for road-based transport bookings (car, bus, taxi,
|
||||
* bicycle), so their map lines follow actual streets instead of a straight
|
||||
* as-the-crow-flies line — the same idea as the real transit paths (#1065),
|
||||
* but routed on demand rather than stored on the reservation.
|
||||
*
|
||||
* Trains and "other transport" keep their straight line (rail/unknown modes
|
||||
* aren't road-routable); flights/cruises/ferries use the great-circle arc.
|
||||
* Any routing failure falls back to the straight line the overlay already draws.
|
||||
*/
|
||||
|
||||
const ROAD_PROFILE: Record<string, 'driving' | 'cycling'> = {
|
||||
car: 'driving',
|
||||
bus: 'driving',
|
||||
taxi: 'driving',
|
||||
bicycle: 'cycling',
|
||||
}
|
||||
|
||||
// Beyond this straight-line distance a car/taxi/bike (or even coach) booking is
|
||||
// almost always a data quirk or an inter-continental hop the road router can't
|
||||
// resolve — keep the straight line and don't hammer the public OSRM demo.
|
||||
const MAX_ROUTE_KM = 2000
|
||||
|
||||
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const R = 6371
|
||||
const dLat = (b.lat - a.lat) * Math.PI / 180
|
||||
const dLng = (b.lng - a.lng) * Math.PI / 180
|
||||
const la1 = a.lat * Math.PI / 180
|
||||
const la2 = b.lat * Math.PI / 180
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLng / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(h))
|
||||
}
|
||||
|
||||
function orderedWaypoints(r: Reservation): ReservationEndpoint[] {
|
||||
return (r.endpoints || [])
|
||||
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of reservation id → routed [lat, lng] polyline for the
|
||||
* road-based bookings among `reservations`. Missing entries mean "not routed
|
||||
* (yet / not routable)" — the caller should draw its straight line for those.
|
||||
* Routing runs once per reservation waypoint-set and is cached across the app
|
||||
* by RouteCalculator, so day switches and re-renders don't re-fetch.
|
||||
*/
|
||||
export function useTransportRoutes(reservations: Reservation[]): Map<number, [number, number][]> {
|
||||
const [routes, setRoutes] = useState<Map<number, [number, number][]>>(new Map())
|
||||
// id → waypoint signature already fetched/attempted, so an unchanged booking
|
||||
// is never re-requested even as the reservations array identity churns.
|
||||
const attemptedRef = useRef<Map<number, string>>(new Map())
|
||||
// Aborted only on unmount — a reservations change must not cancel an
|
||||
// in-flight route we still want.
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
if (!abortRef.current) abortRef.current = new AbortController()
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
useEffect(() => {
|
||||
const jobs: { id: number; profile: 'driving' | 'cycling'; points: { lat: number; lng: number }[] }[] = []
|
||||
for (const r of reservations) {
|
||||
const profile = ROAD_PROFILE[r.type]
|
||||
if (!profile) continue
|
||||
const wps = orderedWaypoints(r)
|
||||
if (wps.length < 2) continue
|
||||
let dist = 0
|
||||
for (let i = 0; i < wps.length - 1; i++) dist += haversineKm(wps[i], wps[i + 1])
|
||||
if (dist > MAX_ROUTE_KM) continue
|
||||
const key = `${profile}:${wps.map(w => `${w.lat},${w.lng}`).join('|')}`
|
||||
if (attemptedRef.current.get(r.id) === key) continue
|
||||
attemptedRef.current.set(r.id, key)
|
||||
jobs.push({ id: r.id, profile, points: wps.map(w => ({ lat: w.lat, lng: w.lng })) })
|
||||
}
|
||||
if (!jobs.length) return
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
// Sequential to stay gentle on the shared public router.
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const result = await calculateRouteWithLegs(job.points, { signal: abortRef.current?.signal, profile: job.profile })
|
||||
if (cancelled) return
|
||||
if (result.coordinates.length >= 2) {
|
||||
setRoutes(prev => {
|
||||
const next = new Map(prev)
|
||||
next.set(job.id, result.coordinates)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Leave it unrouted — the overlay keeps the straight line.
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [reservations])
|
||||
|
||||
return routes
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus } from 'lucide-react'
|
||||
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus, Tags } from 'lucide-react'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import Modal from '../components/shared/Modal'
|
||||
import ListsRail from '../components/Collections/ListsRail'
|
||||
@@ -13,6 +13,8 @@ import MoveToListModal from '../components/Collections/MoveToListModal'
|
||||
import ShareCollectionModal from '../components/Collections/ShareCollectionModal'
|
||||
import AddPlaceToCollectionModal from '../components/Collections/AddPlaceToCollectionModal'
|
||||
import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail'
|
||||
import LabelManager from '../components/Collections/LabelManager'
|
||||
import BulkAssignLabelModal from '../components/Collections/BulkAssignLabelModal'
|
||||
import { useCollections } from './collections/useCollections'
|
||||
import '../styles/dashboard.css'
|
||||
import '../styles/collections.css'
|
||||
@@ -41,6 +43,9 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
const hasPlaces = c.places.length > 0
|
||||
const noLists = !c.loading && c.collections.length === 0
|
||||
const showSelect = c.isAllSaved || c.activeCollection != null
|
||||
// Labels are per-collection, so only on a real (non "All saved") list.
|
||||
const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
|
||||
const canManageLabels = isRealList && c.canEdit
|
||||
|
||||
// Selecting a place toggles it, so clicking it again — or the map background —
|
||||
// clears it. Below the desktop breakpoint the list and map are separate views;
|
||||
@@ -66,6 +71,7 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
const listEl = (
|
||||
<CollectionList
|
||||
places={c.visiblePlaces}
|
||||
labels={c.labels}
|
||||
selectedPlaceId={c.selectedPlaceId}
|
||||
selectMode={c.selectMode}
|
||||
selectedIds={c.selectedIds}
|
||||
@@ -103,6 +109,12 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
categoryOptions={c.categoryOptions}
|
||||
onStatusFilter={c.setStatusFilter}
|
||||
onCategoryFilter={c.setCategoryFilter}
|
||||
showLabels={isRealList}
|
||||
labelOptions={c.labelOptions}
|
||||
labelFilter={c.labelFilter}
|
||||
onLabelFilter={c.setLabelFilter}
|
||||
canManageLabels={canManageLabels}
|
||||
onManageLabels={() => c.setShowLabelManager(true)}
|
||||
showSelect={showSelect}
|
||||
selectMode={c.selectMode}
|
||||
onToggleSelect={() => c.setSelectMode(!c.selectMode)}
|
||||
@@ -231,6 +243,11 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
</button>
|
||||
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
|
||||
<div className="col-toolbar-spacer" />
|
||||
{c.canEdit && isRealList && (
|
||||
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<Tags size={14} /> {t('collections.labels.assign')}
|
||||
</button>
|
||||
)}
|
||||
{c.canEdit && (
|
||||
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<FolderInput size={14} /> {t('collections.moveToList')}
|
||||
@@ -282,6 +299,7 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
canEdit={c.canEdit}
|
||||
canDelete={c.canDelete}
|
||||
categories={c.categories}
|
||||
labels={c.labels}
|
||||
anchorRect={desktopSplit ? c.listColRect : null}
|
||||
onClose={c.handleCloseDetail}
|
||||
onSetStatus={c.handleDetailStatus}
|
||||
@@ -344,6 +362,32 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
{/* Create / edit a list — name, colour, cover, description, links */}
|
||||
<ListEditorModal target={c.editorTarget} onClose={() => c.setEditorTarget(null)} onCreated={c.handleEditorCreated} onRequestDelete={c.setConfirmDeleteList} t={t} />
|
||||
|
||||
{/* Manage the list's custom labels (editor+) */}
|
||||
{canManageLabels && (
|
||||
<LabelManager
|
||||
isOpen={c.showLabelManager}
|
||||
labels={c.labels}
|
||||
onCreate={c.handleCreateLabel}
|
||||
onUpdate={c.handleUpdateLabel}
|
||||
onDelete={c.handleDeleteLabel}
|
||||
onClose={() => c.setShowLabelManager(false)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk-assign labels to the current selection */}
|
||||
{canManageLabels && (
|
||||
<BulkAssignLabelModal
|
||||
isOpen={c.labelPickerOpen}
|
||||
labels={c.labels}
|
||||
count={c.selectedIds.length}
|
||||
onAssign={c.handleBulkAssignLabels}
|
||||
onManage={() => { c.setLabelPickerOpen(false); c.setShowLabelManager(true) }}
|
||||
onClose={() => c.setLabelPickerOpen(false)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete-list confirm */}
|
||||
<Modal
|
||||
isOpen={c.confirmDeleteList != null}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||
import { getFlightLegs } from '../utils/flightLegs'
|
||||
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs'
|
||||
import { splitReservationDateTime } from '../utils/formatters'
|
||||
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
@@ -226,7 +226,14 @@ export default function SharedTripPage() {
|
||||
sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') {
|
||||
if (r.__leg) {
|
||||
// One leg of a multi-leg train — show this segment's own train/route.
|
||||
sub = [r.__leg.train_number, r.__leg.platform ? `Gl. ${r.__leg.platform}` : '', (r.__leg.from || r.__leg.to) ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' · ')
|
||||
} else {
|
||||
sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div key={r.__leg ? `t-${r.id}-leg${r.__leg.index}` : `t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
|
||||
<div className="bg-[rgba(59,130,246,0.12)]" style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
@@ -298,8 +305,11 @@ export default function SharedTripPage() {
|
||||
? getFlightLegs(r).map((leg, i) => (
|
||||
<span key={i}>{[leg.airline, leg.flight_number, (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
|
||||
))
|
||||
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||
{meta.train_number && <span>{meta.train_number}</span>}
|
||||
: r.type === 'train'
|
||||
? getTrainLegs(r).map((leg, i) => (
|
||||
<span key={i}>{[leg.train_number, leg.platform ? `${t('reservations.meta.platform')} ${leg.platform}` : '', (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
|
||||
))
|
||||
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className={r.status === 'confirmed' ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', padding: '2px 8px', borderRadius: 20, fontWeight: 600 }}>
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
sortPlaces,
|
||||
statusCounts,
|
||||
presentCategories,
|
||||
presentLabels,
|
||||
mappablePlaces,
|
||||
normalizeLinkUrl,
|
||||
} from './collectionsModel';
|
||||
import type { CollectionLabel } from '@trek/shared';
|
||||
|
||||
// ── Inline CollectionPlace-ish builder ────────────────────────────────────────
|
||||
// Only the fields the helpers actually read are meaningful; the rest satisfy the
|
||||
@@ -32,6 +34,7 @@ interface PlaceLike {
|
||||
notes?: string | null;
|
||||
sort_order?: number;
|
||||
created_at?: string;
|
||||
label_ids?: number[];
|
||||
}
|
||||
function cp(overrides: PlaceLike): CollectionPlace {
|
||||
return {
|
||||
@@ -319,4 +322,46 @@ describe('collectionsModel', () => {
|
||||
expect(normalizeLinkUrl('//booking.com')).toBe('https://booking.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('labels', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'Berlin gate', status: 'idea', label_ids: [10] }),
|
||||
cp({ id: 2, name: 'Hamburg port', status: 'idea', label_ids: [11] }),
|
||||
cp({ id: 3, name: 'Both', status: 'idea', label_ids: [10, 11] }),
|
||||
cp({ id: 4, name: 'None', status: 'idea', label_ids: [] }),
|
||||
];
|
||||
const labels: CollectionLabel[] = [
|
||||
{ id: 10, collection_id: 1, name: 'Berlin', color: '#f00' },
|
||||
{ id: 11, collection_id: 1, name: 'Hamburg', color: '#00f' },
|
||||
{ id: 12, collection_id: 1, name: 'Unused', color: null },
|
||||
];
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-031: an empty label filter keeps every place', () => {
|
||||
expect(filterPlaces(places, 'all', '', 'all', [])).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-032: a single label keeps places carrying it (incl. multi-label)', () => {
|
||||
const out = filterPlaces(places, 'all', '', 'all', [10]).map(p => p.id);
|
||||
expect(out).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-033: multiple labels are OR — any match passes', () => {
|
||||
const out = filterPlaces(places, 'all', '', 'all', [10, 11]).map(p => p.id);
|
||||
expect(out).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-034: the label filter composes with status + search', () => {
|
||||
const mixed = [
|
||||
cp({ id: 5, name: 'Museum', status: 'visited', label_ids: [10] }),
|
||||
cp({ id: 6, name: 'Museum', status: 'idea', label_ids: [10] }),
|
||||
];
|
||||
const out = filterPlaces(mixed, 'visited', 'mus', 'all', [10]).map(p => p.id);
|
||||
expect(out).toEqual([5]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-035: presentLabels keeps definition order with per-label counts, incl. zero', () => {
|
||||
const opts = presentLabels(labels, places);
|
||||
expect(opts.map(o => [o.id, o.count])).toEqual([[10, 2], [11, 2], [12, 0]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Circle, Bookmark, CheckCircle2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
|
||||
import { COLLECTION_STATUSES } from '@trek/shared'
|
||||
import type { StatusFilter } from '../../store/collectionStore'
|
||||
|
||||
@@ -44,18 +44,23 @@ export function sortPlaces(places: CollectionPlace[]): CollectionPlace[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** Apply the active status filter + free-text search (name/address/notes). */
|
||||
/** Apply the active status filter + free-text search (name/address/notes) +
|
||||
* category + per-collection label filter. The label filter is OR semantics: a
|
||||
* place matches if it carries ANY of the selected labels. This one function
|
||||
* drives BOTH the list and the map, so every filter stays in lockstep. */
|
||||
export function filterPlaces(
|
||||
places: CollectionPlace[],
|
||||
statusFilter: StatusFilter,
|
||||
search: string,
|
||||
categoryFilter: number | 'all' = 'all',
|
||||
labelFilter: number[] = [],
|
||||
): CollectionPlace[] {
|
||||
const q = search.trim().toLowerCase()
|
||||
return places.filter(p => {
|
||||
if (!p) return false
|
||||
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
||||
if (categoryFilter !== 'all' && (p.category_id ?? null) !== categoryFilter) return false
|
||||
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
|
||||
if (!q) return true
|
||||
return (
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
@@ -86,6 +91,20 @@ export function presentCategories(places: CollectionPlace[]): CategoryOption[] {
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export interface LabelOption { id: number; name: string; color: string | null; count: number }
|
||||
|
||||
/** The collection's labels in definition order, each with how many of the given
|
||||
* places carry it. Zero-count labels are kept so the manager/filter still lists
|
||||
* a freshly-created label. */
|
||||
export function presentLabels(labels: CollectionLabel[], places: CollectionPlace[]): LabelOption[] {
|
||||
const counts = new Map<number, number>()
|
||||
for (const p of places) {
|
||||
if (!p) continue
|
||||
for (const id of p.label_ids ?? []) counts.set(id, (counts.get(id) ?? 0) + 1)
|
||||
}
|
||||
return labels.map(l => ({ id: l.id, name: l.name, color: l.color ?? null, count: counts.get(l.id) ?? 0 }))
|
||||
}
|
||||
|
||||
/** Only the places that can render on a map. */
|
||||
export function mappablePlaces(places: CollectionPlace[]): CollectionPlace[] {
|
||||
return places.filter(p => p && typeof p.lat === 'number' && typeof p.lng === 'number')
|
||||
|
||||
@@ -13,7 +13,8 @@ import type { ActiveCollectionId } from '../../store/collectionStore'
|
||||
import { categoriesApi } from '../../api/client'
|
||||
import type { Collection, CollectionStatus } from '@trek/shared'
|
||||
import type { Category, Place } from '../../types'
|
||||
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories } from './collectionsModel'
|
||||
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories, presentLabels } from './collectionsModel'
|
||||
import type { CollectionLabelUpdateRequest } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Collections page logic — owns the page-local UI state (new/edit-list forms,
|
||||
@@ -48,15 +49,16 @@ export function useCollections() {
|
||||
|
||||
const store = useCollectionStore()
|
||||
const {
|
||||
collections, activeId, places, members, incomingInvites,
|
||||
view, statusFilter, categoryFilter, search, selectedPlaceId, selectMode, selectedIds,
|
||||
collections, activeId, places, members, labels, incomingInvites,
|
||||
view, statusFilter, categoryFilter, labelFilter, search, selectedPlaceId, selectMode, selectedIds,
|
||||
loading, placesLoading,
|
||||
loadAll, setActive, refreshActive, loadCollection,
|
||||
deleteCollection,
|
||||
setStatus, updatePlace, deletePlace, deleteMany, copyToTrip, clearSelection,
|
||||
moveToList, duplicateToList, setSelectedIds,
|
||||
createLabel, updateLabel, deleteLabel, assignLabels,
|
||||
acceptInvite, declineInvite,
|
||||
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
} = store
|
||||
|
||||
// ── Page-local UI state ─────────────────────────────────────────────
|
||||
@@ -163,12 +165,15 @@ export function useCollections() {
|
||||
const ownedLists = useMemo(() => collections.filter(c => c.is_owner !== false), [collections])
|
||||
const sharedLists = useMemo(() => collections.filter(c => c.is_owner === false), [collections])
|
||||
|
||||
// Labels are per-collection, so never apply them on the "All saved" union.
|
||||
const visiblePlaces = useMemo(
|
||||
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter)),
|
||||
[places, statusFilter, search, categoryFilter],
|
||||
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter, isAllSaved ? [] : labelFilter)),
|
||||
[places, statusFilter, search, categoryFilter, isAllSaved, labelFilter],
|
||||
)
|
||||
// Categories actually present in this list, for the category filter dropdown.
|
||||
const categoryOptions = useMemo(() => presentCategories(places), [places])
|
||||
// The active list's labels (with per-label counts) for the filter + manager.
|
||||
const labelOptions = useMemo(() => presentLabels(labels, places), [labels, places])
|
||||
// Stable reference so the map doesn't tear down + rebuild every marker on each
|
||||
// unrelated re-render (which would swallow marker clicks mid-rebuild).
|
||||
const mappable = useMemo(() => mappablePlaces(visiblePlaces), [visiblePlaces])
|
||||
@@ -254,6 +259,43 @@ export function useCollections() {
|
||||
}
|
||||
}, [selectedIds, duplicateToList, toast, t])
|
||||
|
||||
// ── Labels ──────────────────────────────────────────────────────────
|
||||
const [showLabelManager, setShowLabelManager] = useState(false)
|
||||
const [labelPickerOpen, setLabelPickerOpen] = useState(false)
|
||||
|
||||
const handleCreateLabel = useCallback(async (name: string, color?: string) => {
|
||||
try { await createLabel(name, color) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [createLabel, toast, t])
|
||||
|
||||
const handleUpdateLabel = useCallback(async (labelId: number, body: CollectionLabelUpdateRequest) => {
|
||||
try { await updateLabel(labelId, body) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [updateLabel, toast, t])
|
||||
|
||||
const handleDeleteLabel = useCallback(async (labelId: number) => {
|
||||
try { await deleteLabel(labelId) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [deleteLabel, toast, t])
|
||||
|
||||
// Bulk-assign one or more labels to the current selection.
|
||||
const handleBulkAssignLabels = useCallback(async (labelIds: number[]) => {
|
||||
if (selectedIds.length === 0 || labelIds.length === 0) return
|
||||
try {
|
||||
await assignLabels(labelIds, selectedIds)
|
||||
toast.success(t('collections.labels.assignedCount', { count: selectedIds.length }))
|
||||
setLabelPickerOpen(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}, [assignLabels, selectedIds, toast, t])
|
||||
|
||||
// Replace a single place's labels (from the detail sheet chips).
|
||||
const handleAssignPlaceLabels = useCallback(async (placeId: number, labelIds: number[]) => {
|
||||
try { await updatePlace(placeId, { label_ids: labelIds }) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [updatePlace, toast, t])
|
||||
|
||||
const handleAcceptInvite = useCallback(async (collectionId: number) => {
|
||||
try {
|
||||
await acceptInvite(collectionId)
|
||||
@@ -337,10 +379,14 @@ export function useCollections() {
|
||||
canShare, shareMemberCount,
|
||||
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
|
||||
view, statusFilter, categoryFilter, categoryOptions, search, selectedPlaceId, selectMode, selectedIds,
|
||||
labels, labelFilter, labelOptions,
|
||||
loading, placesLoading,
|
||||
// store setters
|
||||
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
updatePlace,
|
||||
// labels
|
||||
showLabelManager, setShowLabelManager, labelPickerOpen, setLabelPickerOpen,
|
||||
handleCreateLabel, handleUpdateLabel, handleDeleteLabel, handleBulkAssignLabels, handleAssignPlaceLabels,
|
||||
// local UI state
|
||||
editorTarget, setEditorTarget, handleEditorCreated,
|
||||
showAddPlace, setShowAddPlace, handlePlaceAdded,
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
CollectionCreateRequest,
|
||||
CollectionUpdateRequest,
|
||||
CollectionPlaceUpdateRequest,
|
||||
CollectionLabel,
|
||||
CollectionLabelUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
|
||||
/** A pending invitation the current user has received (derived server-side). */
|
||||
@@ -27,10 +29,12 @@ interface CollectionState {
|
||||
activeId: ActiveCollectionId
|
||||
places: CollectionPlace[]
|
||||
members: CollectionMember[]
|
||||
labels: CollectionLabel[]
|
||||
incomingInvites: IncomingCollectionInvite[]
|
||||
view: CollectionView
|
||||
statusFilter: StatusFilter
|
||||
categoryFilter: number | 'all'
|
||||
labelFilter: number[]
|
||||
search: string
|
||||
selectedPlaceId: number | null
|
||||
selectMode: boolean
|
||||
@@ -57,6 +61,11 @@ interface CollectionState {
|
||||
moveToList: (placeIds: number[], targetId: number) => Promise<void>
|
||||
duplicateToList: (placeIds: number[], targetId: number) => Promise<void>
|
||||
|
||||
createLabel: (name: string, color?: string) => Promise<void>
|
||||
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void>
|
||||
deleteLabel: (labelId: number) => Promise<void>
|
||||
assignLabels: (labelIds: number[], placeIds: number[], remove?: boolean) => Promise<void>
|
||||
|
||||
invite: (collectionId: number, userId: number, role?: CollectionRole) => Promise<void>
|
||||
setMemberRole: (collectionId: number, userId: number, role: CollectionRole) => Promise<void>
|
||||
acceptInvite: (collectionId: number) => Promise<void>
|
||||
@@ -68,6 +77,7 @@ interface CollectionState {
|
||||
setView: (view: CollectionView) => void
|
||||
setStatusFilter: (filter: StatusFilter) => void
|
||||
setCategoryFilter: (filter: number | 'all') => void
|
||||
setLabelFilter: (labelIds: number[]) => void
|
||||
setSearch: (search: string) => void
|
||||
setSelectedPlaceId: (id: number | null) => void
|
||||
setSelectMode: (on: boolean) => void
|
||||
@@ -81,10 +91,12 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
activeId: null,
|
||||
places: [],
|
||||
members: [],
|
||||
labels: [],
|
||||
incomingInvites: [],
|
||||
view: 'list',
|
||||
statusFilter: 'all',
|
||||
categoryFilter: 'all',
|
||||
labelFilter: [],
|
||||
search: '',
|
||||
selectedPlaceId: null,
|
||||
selectMode: false,
|
||||
@@ -110,26 +122,28 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
activeId: id,
|
||||
places: data.places,
|
||||
members: data.collection.members ?? [],
|
||||
labels: data.collection.labels ?? [],
|
||||
})
|
||||
} catch {
|
||||
// The list may have been left / removed / deleted out from under us (a WS
|
||||
// event or the URL sync can re-request an id we just lost access to). Clear
|
||||
// it instead of leaving an uncaught 403/404 rejection; the route sync then
|
||||
// bounces to /collections.
|
||||
if (get().activeId === id) set({ activeId: null, places: [], members: [] })
|
||||
if (get().activeId === id) set({ activeId: null, places: [], members: [], labels: [] })
|
||||
} finally {
|
||||
set({ placesLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
setActive: async (id: ActiveCollectionId) => {
|
||||
set({ selectMode: false, selectedIds: [], selectedPlaceId: null })
|
||||
// Labels are per-collection, so their filter can't carry across lists.
|
||||
set({ selectMode: false, selectedIds: [], selectedPlaceId: null, labelFilter: [] })
|
||||
if (id === null) {
|
||||
set({ activeId: null, places: [], members: [] })
|
||||
set({ activeId: null, places: [], members: [], labels: [] })
|
||||
return
|
||||
}
|
||||
if (id === ALL_SAVED) {
|
||||
set({ activeId: ALL_SAVED, members: [], placesLoading: true })
|
||||
set({ activeId: ALL_SAVED, members: [], labels: [], placesLoading: true })
|
||||
try {
|
||||
// Client-side union of every list the user owns or co-owns (no server change).
|
||||
// On first load the lists may not be fetched yet (loadAll still in flight),
|
||||
@@ -277,6 +291,51 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
await get().loadAll()
|
||||
},
|
||||
|
||||
createLabel: async (name: string, color?: string) => {
|
||||
const active = get().activeId
|
||||
if (typeof active !== 'number') return
|
||||
await collectionsApi.createLabel(active, name, color)
|
||||
await get().loadCollection(active)
|
||||
},
|
||||
|
||||
updateLabel: async (labelId: number, body: CollectionLabelUpdateRequest) => {
|
||||
// optimistic recolor/rename
|
||||
set({ labels: get().labels.map(l => (l.id === labelId ? { ...l, ...body } : l)) })
|
||||
await collectionsApi.updateLabel(labelId, body)
|
||||
const active = get().activeId
|
||||
if (typeof active === 'number') await get().loadCollection(active)
|
||||
},
|
||||
|
||||
deleteLabel: async (labelId: number) => {
|
||||
// optimistic: drop the label + its assignments + any active filter on it
|
||||
set({
|
||||
labels: get().labels.filter(l => l.id !== labelId),
|
||||
labelFilter: get().labelFilter.filter(id => id !== labelId),
|
||||
places: get().places.map(p => ({ ...p, label_ids: (p.label_ids ?? []).filter(id => id !== labelId) })),
|
||||
})
|
||||
await collectionsApi.deleteLabel(labelId)
|
||||
const active = get().activeId
|
||||
if (typeof active === 'number') await get().loadCollection(active)
|
||||
},
|
||||
|
||||
assignLabels: async (labelIds: number[], placeIds: number[], remove = false) => {
|
||||
const idSet = new Set(placeIds)
|
||||
// optimistic per-place label_ids update
|
||||
set({
|
||||
places: get().places.map(p => {
|
||||
if (!idSet.has(p.id)) return p
|
||||
const current = new Set(p.label_ids ?? [])
|
||||
if (remove) labelIds.forEach(id => current.delete(id))
|
||||
else labelIds.forEach(id => current.add(id))
|
||||
return { ...p, label_ids: [...current] }
|
||||
}),
|
||||
})
|
||||
if (remove) await collectionsApi.unassignLabels(labelIds, placeIds)
|
||||
else await collectionsApi.assignLabels(labelIds, placeIds)
|
||||
const active = get().activeId
|
||||
if (typeof active === 'number') await get().loadCollection(active)
|
||||
},
|
||||
|
||||
invite: async (collectionId: number, userId: number, role?: CollectionRole) => {
|
||||
await collectionsApi.invite(collectionId, userId, role)
|
||||
if (get().activeId === collectionId) await get().loadCollection(collectionId)
|
||||
@@ -316,6 +375,7 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
setView: (view: CollectionView) => set({ view }),
|
||||
setStatusFilter: (filter: StatusFilter) => set({ statusFilter: filter }),
|
||||
setCategoryFilter: (filter: number | 'all') => set({ categoryFilter: filter }),
|
||||
setLabelFilter: (labelIds: number[]) => set({ labelFilter: labelIds }),
|
||||
setSearch: (search: string) => set({ search }),
|
||||
setSelectedPlaceId: (id: number | null) => set({ selectedPlaceId: id }),
|
||||
setSelectMode: (on: boolean) => set({ selectMode: on, selectedIds: on ? get().selectedIds : [] }),
|
||||
|
||||
@@ -270,6 +270,20 @@
|
||||
.trek-dash .col-lrow-cat svg { flex-shrink: 0; }
|
||||
.trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); }
|
||||
|
||||
/* Per-collection label chips — list rows, filter bar and detail read view */
|
||||
.trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); }
|
||||
.trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); }
|
||||
.trek-dash .col-labelchip-dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; background: var(--label, var(--accent)); }
|
||||
.trek-dash .col-labelfilter { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
|
||||
.trek-dash .col-labelchip:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; }
|
||||
.trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); }
|
||||
.trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); }
|
||||
.trek-dash .col-labelchip.static { cursor: default; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); border-color: transparent; }
|
||||
.trek-dash .col-labelchip-manage { color: var(--ink-3); border-style: dashed; }
|
||||
.trek-dash .col-detail-labels { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 10px; }
|
||||
|
||||
/* ----------------- filter bar (status + category dropdowns) ----------------- */
|
||||
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||
.trek-dash .col-filter { position: relative; }
|
||||
|
||||
@@ -128,6 +128,32 @@ describe('getTransportForDay', () => {
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('expands a multi-leg TRAIN into one row per leg with train detail on __leg (#1150)', () => {
|
||||
const reservations = [{
|
||||
id: 40, type: 'train', day_id: 1, end_day_id: 2,
|
||||
metadata: JSON.stringify({
|
||||
train_number: 'ICE 100',
|
||||
legs: [
|
||||
{ from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5', dep_day_id: 1, dep_time: '08:00', arr_day_id: 1, arr_time: '12:00' },
|
||||
{ from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9', dep_day_id: 2, dep_time: '09:00', arr_day_id: 2, arr_time: '12:00' },
|
||||
],
|
||||
}),
|
||||
}]
|
||||
const day1 = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
|
||||
expect(day1).toHaveLength(1)
|
||||
expect(day1[0].__leg).toMatchObject({ index: 0, total: 2, from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5' })
|
||||
const day2 = getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })
|
||||
expect(day2).toHaveLength(1)
|
||||
expect(day2[0].__leg).toMatchObject({ index: 1, from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9' })
|
||||
})
|
||||
|
||||
it('leaves a single-leg train untouched (no __leg)', () => {
|
||||
const reservations = [{ id: 41, type: 'train', day_id: 1, end_day_id: 1, metadata: JSON.stringify({ train_number: 'RE 1' }) }]
|
||||
const rows = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].__leg).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMergedItems', () => {
|
||||
|
||||
@@ -66,23 +66,23 @@ export function getDisplayTimeForDay(
|
||||
return r.reservation_time || null
|
||||
}
|
||||
|
||||
/** Per-leg detail of a multi-leg flight, or null for single-leg / non-flight. */
|
||||
function parseFlightLegs(r: any): any[] | null {
|
||||
if (r?.type !== 'flight') return null
|
||||
/** Per-leg detail of a multi-leg flight or train, or null for single-leg / other. */
|
||||
function parseMultiLegs(r: any): any[] | null {
|
||||
if (r?.type !== 'flight' && r?.type !== 'train') return null
|
||||
let meta = r.metadata
|
||||
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||
// Defensive: recover metadata that was accidentally double-encoded by an earlier
|
||||
// bug (a JSON string of a JSON string) so already-saved flights heal on read.
|
||||
// bug (a JSON string of a JSON string) so already-saved bookings heal on read.
|
||||
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||
if (meta && Array.isArray(meta.legs) && meta.legs.length > 1) return meta.legs
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a multi-leg flight into one synthetic reservation per leg that touches
|
||||
* `dayId`, each with its own day span + departure/arrival time so it slots into
|
||||
* the timeline independently. A single-leg flight (or any other reservation) is
|
||||
* returned untouched, so existing behaviour is unchanged.
|
||||
* Expand a multi-leg flight/train into one synthetic reservation per leg that
|
||||
* touches `dayId`, each with its own day span + departure/arrival time so it
|
||||
* slots into the timeline independently. A single-leg booking (or any other
|
||||
* reservation) is returned untouched, so existing behaviour is unchanged.
|
||||
*/
|
||||
export function expandFlightLegsForDay(
|
||||
r: any,
|
||||
@@ -90,7 +90,7 @@ export function expandFlightLegsForDay(
|
||||
getDayOrder: (id: number) => number,
|
||||
days: Array<{ id: number; date?: string | null }>
|
||||
): any[] {
|
||||
const legs = parseFlightLegs(r)
|
||||
const legs = parseMultiLegs(r)
|
||||
if (!legs) return [r]
|
||||
const dateOf = (id: number | null): string | null => (id == null ? null : (days.find(d => d.id === id)?.date ?? null))
|
||||
const thisOrder = getDayOrder(dayId)
|
||||
@@ -114,7 +114,14 @@ export function expandFlightLegsForDay(
|
||||
// dropped between legs and persist; absent → falls back to time ordering.
|
||||
day_positions: leg.day_positions || undefined,
|
||||
day_plan_position: undefined,
|
||||
__leg: { index: i, total: legs.length, from: leg.from ?? null, to: leg.to ?? null, airline: leg.airline ?? null, flight_number: leg.flight_number ?? null },
|
||||
__leg: {
|
||||
index: i, total: legs.length,
|
||||
from: leg.from ?? null, to: leg.to ?? null,
|
||||
airline: leg.airline ?? null, flight_number: leg.flight_number ?? null,
|
||||
// Train legs carry their own per-leg detail; added only for trains so a
|
||||
// flight's __leg object stays byte-identical to before.
|
||||
...(r.type === 'train' ? { train_number: leg.train_number ?? null, platform: leg.platform ?? null, seat: leg.seat ?? null } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFlightLegs, getTrainLegs, isMultiLegTrain } from './flightLegs'
|
||||
import type { Reservation } from '../types'
|
||||
|
||||
function res(partial: Partial<Reservation>): Reservation {
|
||||
return { id: 1, type: 'train', status: 'confirmed', ...partial } as unknown as Reservation
|
||||
}
|
||||
|
||||
const ep = (role: 'from' | 'to' | 'stop', seq: number, name: string, extra: Record<string, unknown> = {}) =>
|
||||
({ role, sequence: seq, name, code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null, ...extra })
|
||||
|
||||
describe('getTrainLegs (#1150)', () => {
|
||||
it('reads ordered legs from metadata.legs', () => {
|
||||
const r = res({
|
||||
metadata: JSON.stringify({
|
||||
train_number: 'ICE 100', platform: '5',
|
||||
legs: [
|
||||
{ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5', dep_time: '08:00', arr_time: '12:00' },
|
||||
{ from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 500', platform: '9', dep_time: '12:30', arr_time: '15:30' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
const legs = getTrainLegs(r)
|
||||
expect(legs).toHaveLength(2)
|
||||
expect(legs[0]).toMatchObject({ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5' })
|
||||
expect(legs[1]).toMatchObject({ from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 500', platform: '9' })
|
||||
expect(isMultiLegTrain(r)).toBe(true)
|
||||
})
|
||||
|
||||
it('derives a single leg from endpoints + flat metadata (legacy train)', () => {
|
||||
const r = res({
|
||||
day_id: 3, end_day_id: 3,
|
||||
metadata: JSON.stringify({ train_number: 'RE 42', platform: '2' }),
|
||||
endpoints: [ep('from', 0, 'Köln Hbf', { local_time: '09:00' }), ep('to', 1, 'Aachen Hbf', { local_time: '10:00' })],
|
||||
})
|
||||
const legs = getTrainLegs(r)
|
||||
expect(legs).toHaveLength(1)
|
||||
expect(legs[0]).toMatchObject({ from: 'Köln Hbf', to: 'Aachen Hbf', train_number: 'RE 42', platform: '2', dep_time: '09:00', arr_time: '10:00' })
|
||||
expect(isMultiLegTrain(r)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns [] for a train with no stations and no train number', () => {
|
||||
expect(getTrainLegs(res({ metadata: '{}' }))).toEqual([])
|
||||
})
|
||||
|
||||
it('does not disturb getFlightLegs for flights', () => {
|
||||
const flight = res({ type: 'flight', metadata: JSON.stringify({ departure_airport: 'FRA', arrival_airport: 'JFK', airline: 'LH', flight_number: 'LH 400' }) })
|
||||
const legs = getFlightLegs(flight)
|
||||
expect(legs).toHaveLength(1)
|
||||
expect(legs[0]).toMatchObject({ from: 'FRA', to: 'JFK', airline: 'LH', flight_number: 'LH 400' })
|
||||
})
|
||||
})
|
||||
@@ -84,11 +84,71 @@ export function getFlightLegs(r: Reservation): FlightLeg[] {
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* A train booking mirrors the flight leg model (#1150), but its stops are
|
||||
* STATIONS (labels, not IATA codes) and each leg carries a train number +
|
||||
* platform instead of an airline + flight number.
|
||||
*/
|
||||
export interface TrainLeg {
|
||||
from: string | null // station label (or null)
|
||||
to: string | null
|
||||
train_number?: string
|
||||
platform?: string
|
||||
seat?: string
|
||||
dep_day_id?: number | null
|
||||
dep_time?: string | null
|
||||
arr_day_id?: number | null
|
||||
arr_time?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered legs of a train booking. Prefers `metadata.legs`; otherwise derives a
|
||||
* single leg from the endpoints + flat metadata, so single-leg trains — and
|
||||
* trains created before this feature — still work.
|
||||
*/
|
||||
export function getTrainLegs(r: Reservation): TrainLeg[] {
|
||||
const meta = parseReservationMetadata(r)
|
||||
if (Array.isArray(meta.legs) && meta.legs.length > 0) {
|
||||
return meta.legs.map((l: any): TrainLeg => ({
|
||||
from: l.from ?? null,
|
||||
to: l.to ?? null,
|
||||
train_number: l.train_number || undefined,
|
||||
platform: l.platform || undefined,
|
||||
seat: l.seat || undefined,
|
||||
dep_day_id: l.dep_day_id ?? null,
|
||||
dep_time: l.dep_time ?? null,
|
||||
arr_day_id: l.arr_day_id ?? null,
|
||||
arr_time: l.arr_time ?? null,
|
||||
}))
|
||||
}
|
||||
const eps = orderedEndpoints(r)
|
||||
const first = eps[0]
|
||||
const last = eps[eps.length - 1]
|
||||
const fromLabel = first ? (first.code || first.name) : null
|
||||
const toLabel = last ? (last.code || last.name) : null
|
||||
if (!fromLabel && !toLabel && !meta.train_number) return []
|
||||
return [{
|
||||
from: fromLabel,
|
||||
to: toLabel,
|
||||
train_number: meta.train_number || undefined,
|
||||
platform: meta.platform || undefined,
|
||||
seat: meta.seat || undefined,
|
||||
dep_day_id: r.day_id ?? null,
|
||||
dep_time: first?.local_time ?? null,
|
||||
arr_day_id: r.end_day_id ?? r.day_id ?? null,
|
||||
arr_time: last?.local_time ?? null,
|
||||
}]
|
||||
}
|
||||
|
||||
/** Number of flight segments. 1 for a simple from -> to booking. */
|
||||
export function legCount(r: Reservation): number {
|
||||
return getFlightLegs(r).length
|
||||
}
|
||||
|
||||
export function isMultiLegTrain(r: Reservation): boolean {
|
||||
return r.type === 'train' && getTrainLegs(r).length > 1
|
||||
}
|
||||
|
||||
export function isMultiLegFlight(r: Reservation): boolean {
|
||||
return r.type === 'flight' && legCount(r) > 1
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitReservationDateTime, resolveDayId } from './formatters'
|
||||
import { splitReservationDateTime, resolveDayId, formatMoney } from './formatters'
|
||||
import { CURRENCIES, SYMBOLS } from '../components/Budget/BudgetPanel.constants'
|
||||
import type { Day } from '../types'
|
||||
|
||||
const days = [
|
||||
@@ -25,6 +26,19 @@ describe('resolveDayId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('KGS currency (#1400)', () => {
|
||||
it('is selectable everywhere the shared currency list feeds', () => {
|
||||
expect(CURRENCIES).toContain('KGS')
|
||||
expect(SYMBOLS.KGS).toBeTruthy()
|
||||
})
|
||||
it('formats without throwing', () => {
|
||||
// Lenient: older ICU builds may lack ru-KG/KGS display data and fall back.
|
||||
const out = formatMoney(1234.56, 'KGS', 'en')
|
||||
expect(out).toMatch(/сом|KGS/)
|
||||
expect(out).toContain('234')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitReservationDateTime', () => {
|
||||
it('parses full ISO datetime', () => {
|
||||
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||
|
||||
@@ -52,9 +52,9 @@ const CURRENCY_LOCALE: Record<string, string> = {
|
||||
PHP: 'en-PH', SGD: 'en-SG', KRW: 'ko-KR', CNY: 'zh-CN', HKD: 'en-HK',
|
||||
TWD: 'zh-TW', ZAR: 'en-ZA', AED: 'en-AE', SAR: 'en-SA', ILS: 'he-IL',
|
||||
EGP: 'en-EG', MAD: 'fr-MA', HUF: 'hu-HU', RON: 'ro-RO', BGN: 'bg-BG',
|
||||
HRK: 'hr-HR', ISK: 'is-IS', RUB: 'ru-RU', UAH: 'uk-UA', BDT: 'en-BD',
|
||||
LKR: 'en-LK', VND: 'vi-VN', CLP: 'es-CL', COP: 'es-CO', PEN: 'es-PE',
|
||||
ARS: 'es-AR',
|
||||
HRK: 'hr-HR', ISK: 'is-IS', RUB: 'ru-RU', UAH: 'uk-UA', KGS: 'ru-KG',
|
||||
BDT: 'en-BD', LKR: 'en-LK', VND: 'vi-VN', CLP: 'es-CL', COP: 'es-CO',
|
||||
PEN: 'es-PE', ARS: 'es-AR',
|
||||
}
|
||||
|
||||
export function currencyLocale(currency: string): string {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Reservation } from '../../../src/types'
|
||||
|
||||
const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
|
||||
vi.mock('../../../src/components/Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
|
||||
|
||||
import { useTransportRoutes } from '../../../src/hooks/useTransportRoutes'
|
||||
|
||||
function booking(id: number, type: string, from: [number, number], to: [number, number]): Reservation {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
status: 'confirmed',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: null, lat: from[0], lng: from[1], timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: null, lat: to[0], lng: to[1], timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
}
|
||||
|
||||
const PARIS: [number, number] = [48.8566, 2.3522]
|
||||
const VERSAILLES: [number, number] = [48.8049, 2.1204]
|
||||
|
||||
beforeEach(() => {
|
||||
calculateRouteWithLegs.mockReset()
|
||||
calculateRouteWithLegs.mockResolvedValue({ coordinates: [PARIS, [48.83, 2.24], VERSAILLES], distance: 20000, duration: 1500, legs: [] })
|
||||
})
|
||||
|
||||
describe('useTransportRoutes (#1425 real road routes)', () => {
|
||||
it('routes a car booking with the driving profile and returns its geometry', async () => {
|
||||
const res = [booking(1, 'car', PARIS, VERSAILLES)]
|
||||
const { result } = renderHook(() => useTransportRoutes(res))
|
||||
await waitFor(() => expect(result.current.get(1)).toBeTruthy())
|
||||
expect(result.current.get(1)).toHaveLength(3)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ profile: 'driving' }))
|
||||
})
|
||||
|
||||
it('routes a bicycle booking with the cycling profile', async () => {
|
||||
const res = [booking(2, 'bicycle', PARIS, VERSAILLES)]
|
||||
renderHook(() => useTransportRoutes(res))
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ profile: 'cycling' }))
|
||||
})
|
||||
|
||||
it('does not route non-road types (flight, train, transit)', async () => {
|
||||
const res = [
|
||||
booking(3, 'flight', PARIS, VERSAILLES),
|
||||
booking(4, 'train', PARIS, VERSAILLES),
|
||||
booking(5, 'transit', PARIS, VERSAILLES),
|
||||
]
|
||||
renderHook(() => useTransportRoutes(res))
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips routing beyond the sanity distance cap (keeps the straight line)', async () => {
|
||||
// Paris → Tokyo as a "car" booking: ~9700 km, well past the 2000 km cap.
|
||||
const res = [booking(6, 'car', PARIS, [35.68, 139.69])]
|
||||
renderHook(() => useTransportRoutes(res))
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back silently (no entry) when routing throws', async () => {
|
||||
calculateRouteWithLegs.mockRejectedValueOnce(new Error('OSRM down'))
|
||||
const res = [booking(7, 'taxi', PARIS, VERSAILLES)]
|
||||
const { result } = renderHook(() => useTransportRoutes(res))
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(result.current.get(7)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not re-request an unchanged booking when the array identity changes', async () => {
|
||||
const res1 = [booking(8, 'car', PARIS, VERSAILLES)]
|
||||
const { rerender } = renderHook(({ r }) => useTransportRoutes(r), { initialProps: { r: res1 } })
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1))
|
||||
// New array, same booking coordinates → no second fetch.
|
||||
rerender({ r: [booking(8, 'car', PARIS, VERSAILLES)] })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Generated
+84
-10
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"version": "3.1.3",
|
||||
"version": "3.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@trek/root",
|
||||
"version": "3.1.3",
|
||||
"version": "3.1.4",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "3.1.3",
|
||||
"version": "3.1.4",
|
||||
"dependencies": {
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
@@ -4127,6 +4127,12 @@
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/tsdoc": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz",
|
||||
"integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
@@ -4757,6 +4763,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/mapped-types": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz",
|
||||
"integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"class-transformer": "^0.4.0 || ^0.5.0",
|
||||
"class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0",
|
||||
"reflect-metadata": "^0.1.12 || ^0.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"class-transformer": {
|
||||
"optional": true
|
||||
},
|
||||
"class-validator": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express": {
|
||||
"version": "11.1.27",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz",
|
||||
@@ -5070,6 +5096,39 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/swagger": {
|
||||
"version": "11.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.5.tgz",
|
||||
"integrity": "sha512-lvndlJmWBVDOUT0uEtLi6sSpW1syK2/nbAlHBhiELBORMpJGe9+EiWAT9qHtB10jW91L2Jmlwkr0/lttsYZrig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/tsdoc": "0.16.0",
|
||||
"@nestjs/mapped-types": "2.1.1",
|
||||
"js-yaml": "4.3.0",
|
||||
"lodash": "4.18.1",
|
||||
"path-to-regexp": "8.4.2",
|
||||
"swagger-ui-dist": "5.32.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fastify/static": "^8.0.0 || ^9.0.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"class-transformer": "*",
|
||||
"class-validator": "*",
|
||||
"reflect-metadata": "^0.1.12 || ^0.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@fastify/static": {
|
||||
"optional": true
|
||||
},
|
||||
"class-transformer": {
|
||||
"optional": true
|
||||
},
|
||||
"class-validator": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/testing": {
|
||||
"version": "11.1.27",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.27.tgz",
|
||||
@@ -6443,6 +6502,13 @@
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
||||
@@ -8106,7 +8172,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
@@ -12885,10 +12950,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"dev": true,
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -18531,6 +18595,15 @@
|
||||
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "5.32.8",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
|
||||
"integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@scarf/scarf": "=1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -21156,12 +21229,13 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@trek/server",
|
||||
"version": "3.1.3",
|
||||
"version": "3.1.4",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@nestjs/swagger": "^11.4.5",
|
||||
"@simplewebauthn/server": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"archiver": "^6.0.1",
|
||||
@@ -21514,7 +21588,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@trek/shared",
|
||||
"version": "3.1.3",
|
||||
"version": "3.1.4",
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^3.15.0",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
+5
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "3.1.3",
|
||||
"version": "3.1.4",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -28,6 +28,10 @@
|
||||
"concurrently": "^10.0.3",
|
||||
"unrun": "^0.3.1"
|
||||
},
|
||||
"comment:scarfSettings": "swagger-ui-dist ships @scarf/scarf install-time analytics; TREK sends no telemetry, so it stays off.",
|
||||
"scarfSettings": {
|
||||
"enabled": false
|
||||
},
|
||||
"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",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "3.1.3",
|
||||
"version": "3.1.4",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --require tsconfig-paths/register dist/index.js",
|
||||
@@ -26,6 +26,7 @@
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@nestjs/swagger": "^11.4.5",
|
||||
"@simplewebauthn/server": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"archiver": "^6.0.1",
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { INestApplication } from '@nestjs/common';
|
||||
import { AppModule } from './nest/app.module';
|
||||
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
|
||||
import { applyPlatformUploads, applyPlatformTransport, applyPlatformStatic } from './nest/platform/platform.routes';
|
||||
import { apiDocsEnabled } from './nest/common/api-docs.kill-switch';
|
||||
import { setupApiDocs } from './nest/platform/api-docs';
|
||||
|
||||
/**
|
||||
* Builds the unified TREK NestJS application that serves the ENTIRE surface — the
|
||||
@@ -27,6 +29,8 @@ import { applyPlatformUploads, applyPlatformTransport, applyPlatformStatic } fro
|
||||
* metadata, the /mcp routes, the /oauth/consent COOP header.
|
||||
* 4. applyPlatformStatic — the production built-client static assets (so a real
|
||||
* asset request returns the file before the Nest router 404s it).
|
||||
* 4b. setupApiDocs — Swagger UI/spec at /api/docs* when TREK_API_DOCS_ENABLED;
|
||||
* also Express-level, so it must precede init for the same reason.
|
||||
* 5. app.init() — registers every migrated /api domain (the Nest controllers).
|
||||
*
|
||||
* The SPA index.html fallback (unmatched GET → index.html in production) is the
|
||||
@@ -40,6 +44,7 @@ export async function buildApp(): Promise<INestApplication> {
|
||||
applyPlatformUploads(instance);
|
||||
applyPlatformTransport(instance);
|
||||
applyPlatformStatic(instance);
|
||||
if (apiDocsEnabled()) setupApiDocs(app);
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -3398,6 +3398,27 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_audit_plugin ON plugin_capability_audit (plugin_id, id);');
|
||||
} catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
|
||||
},
|
||||
// Migration 160: per-collection custom labels (#collections). Each list owns
|
||||
// its own label set (unlike the instance-wide `tags` table), and a place can
|
||||
// carry several labels. Used for grouping + filtering places within a list.
|
||||
() => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS collection_labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS collection_place_labels (
|
||||
collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE,
|
||||
label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (collection_place_id, label_id)
|
||||
);`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id);');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id);');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id);');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -436,6 +436,27 @@ function createTables(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id);
|
||||
|
||||
-- Per-collection custom labels (distinct from the instance-wide tags table):
|
||||
-- each list defines its own labels and every place can carry several.
|
||||
CREATE TABLE IF NOT EXISTS collection_labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_place_labels (
|
||||
collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE,
|
||||
label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (collection_place_id, label_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS day_accommodations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* MCP tuning knobs from the environment (#1414). Pure parsers, kept free of
|
||||
* imports so units can test them without dragging in the MCP SDK.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Session idle TTL in SECONDS via MCP_SESSION_TTL, default 1 hour, clamped to
|
||||
* 24h so a milliseconds-value typo can't produce a 1000-hour session.
|
||||
*/
|
||||
export function resolveSessionTtlMs(raw: string | undefined): number {
|
||||
const parsed = Number.parseInt(raw ?? "");
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 24 * 60 * 60) * 1000 : 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE keep-alive interval in SECONDS via MCP_SSE_KEEPALIVE, default 25s
|
||||
* (below common proxy idle timeouts like nginx-ingress's 60s). 0 disables.
|
||||
*/
|
||||
export function resolveKeepaliveMs(raw: string | undefined): number {
|
||||
const parsed = Number.parseInt(raw ?? "");
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed * 1000 : 25_000;
|
||||
}
|
||||
+34
-1
@@ -10,6 +10,7 @@ import { ADDON_IDS } from '../addons';
|
||||
import { registerResources } from './resources';
|
||||
import { registerTools } from './tools';
|
||||
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
|
||||
import { resolveSessionTtlMs, resolveKeepaliveMs } from './config';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
import { getMcpSafeUrl } from '../services/notifications';
|
||||
|
||||
@@ -95,9 +96,39 @@ const STATIC_TOKEN_DEPRECATION_NOTICE =
|
||||
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
|
||||
'The actual tool result follows — answer the user\'s question as well.';
|
||||
|
||||
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
// Configurable session TTL + SSE keep-alive cadence (#1414); see mcp/config.ts.
|
||||
const SESSION_TTL_MS = resolveSessionTtlMs(process.env.MCP_SESSION_TTL);
|
||||
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
|
||||
const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 20;
|
||||
const KEEPALIVE_MS = resolveKeepaliveMs(process.env.MCP_SSE_KEEPALIVE);
|
||||
|
||||
/**
|
||||
* Write SSE comment frames on an interval while the response is an open
|
||||
* event stream. A no-op for JSON/error responses (content-type gate) and for
|
||||
* per-POST streams that end quickly (writableEnded gate). `touch` refreshes
|
||||
* the session's lastActivity so the sweep never evicts a session whose GET
|
||||
* stream is still connected.
|
||||
*/
|
||||
function armSseKeepalive(res: Response, touch?: () => void): void {
|
||||
// With pings disabled (MCP_SSE_KEEPALIVE=0) an open stream must STILL count
|
||||
// as session activity, or the sweep would evict a live idle client — keep
|
||||
// the interval for touch() and only skip the writes.
|
||||
const writePings = KEEPALIVE_MS > 0;
|
||||
if (!writePings && !touch) return;
|
||||
const intervalMs = writePings ? KEEPALIVE_MS : 25_000;
|
||||
const timer = setInterval(() => {
|
||||
if (!res.headersSent) return;
|
||||
const ct = String(res.getHeader('content-type') ?? '');
|
||||
if (!ct.includes('text/event-stream') || res.writableEnded || res.destroyed) {
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
if (writePings) res.write(': keepalive\n\n');
|
||||
touch?.();
|
||||
}, intervalMs);
|
||||
timer.unref();
|
||||
res.once('close', () => clearInterval(timer));
|
||||
}
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
|
||||
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 300; // requests per minute per user
|
||||
@@ -255,6 +286,7 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
session.lastActivity = Date.now();
|
||||
logToolCallAudit(req, user.id, clientId);
|
||||
armSseKeepalive(res, () => { session.lastActivity = Date.now(); });
|
||||
try {
|
||||
await session.transport.handleRequest(req, res, req.body);
|
||||
} catch (err) {
|
||||
@@ -318,6 +350,7 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
});
|
||||
|
||||
logToolCallAudit(req, user.id, clientId);
|
||||
armSseKeepalive(res);
|
||||
try {
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
|
||||
@@ -203,10 +203,12 @@ export class AdminController {
|
||||
|
||||
@Put('collab-features')
|
||||
updateCollabFeatures(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
||||
const result = this.admin.updateCollabFeatures(body);
|
||||
this.admin.invalidateMcpSessions();
|
||||
writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: result });
|
||||
return result;
|
||||
const { features, changed } = this.admin.updateCollabFeatures(body);
|
||||
// Collab flags gate MCP registration, but a no-op save must not tear down
|
||||
// every live MCP session (#1414).
|
||||
if (changed) this.admin.invalidateMcpSessions();
|
||||
writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features });
|
||||
return features;
|
||||
}
|
||||
|
||||
// ── Packing templates ──
|
||||
@@ -272,7 +274,10 @@ export class AdminController {
|
||||
updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) {
|
||||
const result = ok(this.admin.updateAddon(id, body));
|
||||
writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
|
||||
this.admin.invalidateMcpSessions();
|
||||
// Sessions only need re-creating when the registered MCP surface can
|
||||
// actually change — an enabled-flip of an MCP-relevant addon. Config-only
|
||||
// saves and photo-provider toggles used to kill every session (#1414).
|
||||
if (result.mcpAffected) this.admin.invalidateMcpSessions();
|
||||
return { addon: result.addon };
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ import {
|
||||
collectionInviteCancelRequestSchema,
|
||||
collectionRemoveMemberRequestSchema,
|
||||
collectionSetMemberRoleRequestSchema,
|
||||
collectionLabelCreateRequestSchema,
|
||||
collectionLabelUpdateRequestSchema,
|
||||
collectionLabelAssignRequestSchema,
|
||||
type CollectionLabelCreateRequest,
|
||||
type CollectionLabelUpdateRequest,
|
||||
type CollectionLabelAssignRequest,
|
||||
type CollectionCreateRequest,
|
||||
type CollectionUpdateRequest,
|
||||
type CollectionSavePlaceRequest,
|
||||
@@ -173,6 +179,53 @@ export class CollectionsController {
|
||||
return this.collections.copyToTrip(user.id, body);
|
||||
}
|
||||
|
||||
// ── Labels (per-collection custom labels; static prefixes before /:id) ───────
|
||||
@Post('labels')
|
||||
@HttpCode(200)
|
||||
createLabel(
|
||||
@CurrentUser() user: User,
|
||||
@Body(new ZodValidationPipe(collectionLabelCreateRequestSchema)) body: CollectionLabelCreateRequest,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return this.collections.createLabel(user.id, body.collection_id, body.name, body.color, socketId);
|
||||
}
|
||||
|
||||
@Patch('labels/:lid')
|
||||
updateLabel(
|
||||
@CurrentUser() user: User,
|
||||
@Param('lid') lid: string,
|
||||
@Body(new ZodValidationPipe(collectionLabelUpdateRequestSchema)) body: CollectionLabelUpdateRequest,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return this.collections.updateLabel(user.id, Number(lid), body, socketId);
|
||||
}
|
||||
|
||||
@Delete('labels/:lid')
|
||||
deleteLabel(@CurrentUser() user: User, @Param('lid') lid: string, @Headers('x-socket-id') socketId?: string) {
|
||||
this.collections.deleteLabel(user.id, Number(lid), socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('labels/assign')
|
||||
@HttpCode(200)
|
||||
assignLabels(
|
||||
@CurrentUser() user: User,
|
||||
@Body(new ZodValidationPipe(collectionLabelAssignRequestSchema)) body: CollectionLabelAssignRequest,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return this.collections.assignLabels(user.id, body.label_ids, body.place_ids, false, socketId);
|
||||
}
|
||||
|
||||
@Post('labels/unassign')
|
||||
@HttpCode(200)
|
||||
unassignLabels(
|
||||
@CurrentUser() user: User,
|
||||
@Body(new ZodValidationPipe(collectionLabelAssignRequestSchema)) body: CollectionLabelAssignRequest,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return this.collections.assignLabels(user.id, body.label_ids, body.place_ids, true, socketId);
|
||||
}
|
||||
|
||||
// ── Library-wide membership lookup ──────────────────────────────────────────
|
||||
@Get('membership')
|
||||
membership(
|
||||
|
||||
@@ -41,6 +41,11 @@ export class CollectionsService {
|
||||
return svc.findMembership(userId, query);
|
||||
}
|
||||
|
||||
createLabel(userId: number, collectionId: number, name: string, color: string | undefined, socketId?: string) { return svc.createLabel(userId, collectionId, name, color, socketId); }
|
||||
updateLabel(userId: number, labelId: number, body: { name?: string; color?: string; sort_order?: number }, socketId?: string) { return svc.updateLabel(userId, labelId, body, socketId); }
|
||||
deleteLabel(userId: number, labelId: number, socketId?: string) { return svc.deleteLabel(userId, labelId, socketId); }
|
||||
assignLabels(userId: number, labelIds: number[], placeIds: number[], remove: boolean, socketId?: string) { return svc.assignLabels(userId, labelIds, placeIds, remove, socketId); }
|
||||
|
||||
// Access helpers used by the controller's owner guards.
|
||||
assertAccess(userId: number, collectionId: number) { return svc.assertAccess(userId, collectionId); }
|
||||
isOwner(userId: number, collectionId: number) { return svc.isOwner(userId, collectionId); }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* API-docs kill switch (#1412). Deliberately NOT in src/config.ts: tests mock
|
||||
* that module with partial exports, and reading the env at call time keeps the
|
||||
* flag testable per-request (same pattern as plugins/kill-switch.ts).
|
||||
*
|
||||
* Off by default — the generated spec enumerates every route including the
|
||||
* admin surface, so exposing it is an explicit self-hoster decision.
|
||||
*/
|
||||
export function apiDocsEnabled(): boolean {
|
||||
return (process.env.TREK_API_DOCS_ENABLED || '').trim().toLowerCase() === 'true';
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import { ModulesContainer } from '@nestjs/core';
|
||||
import { PATH_METADATA, METHOD_METADATA, ROUTE_ARGS_METADATA } from '@nestjs/common/constants';
|
||||
import { z, type ZodType } from 'zod';
|
||||
import type { OpenAPIObject } from '@nestjs/swagger';
|
||||
import { ZodValidationPipe } from './zod-validation.pipe';
|
||||
|
||||
/**
|
||||
* Phase 2 of #1412: real request-body schemas without annotating anything
|
||||
* twice. The API validates with Zod via `@Body(new ZodValidationPipe(schema))`
|
||||
* — that pipe IS the schema source of truth, so this enricher walks every
|
||||
* controller route, finds full-body Zod pipes, and writes the converted
|
||||
* JSON schema into the generated OpenAPI document. Any route that gains a
|
||||
* ZodValidationPipe is documented automatically from then on.
|
||||
*/
|
||||
|
||||
/** Zod → OpenAPI 3.0 schema; degrades to a bare object for unrepresentable schemas. */
|
||||
export function zodToOpenApi(schema: ZodType): Record<string, unknown> {
|
||||
try {
|
||||
return z.toJSONSchema(schema, { target: 'openapi-3.0', io: 'input', unrepresentable: 'any' }) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { type: 'object' };
|
||||
}
|
||||
}
|
||||
|
||||
const HTTP_METHOD: Partial<Record<RequestMethod, string>> = {
|
||||
[RequestMethod.GET]: 'get',
|
||||
[RequestMethod.POST]: 'post',
|
||||
[RequestMethod.PUT]: 'put',
|
||||
[RequestMethod.PATCH]: 'patch',
|
||||
[RequestMethod.DELETE]: 'delete',
|
||||
};
|
||||
|
||||
// RouteParamtypes.BODY — the numeric key prefix @Body() writes into
|
||||
// ROUTE_ARGS_METADATA ("<paramtype>:<index>"). The enum lives in a private
|
||||
// @nestjs/common path, so the value is pinned here with a boot-time test.
|
||||
const BODY_PARAMTYPE = '3';
|
||||
|
||||
function joinPath(base: string | undefined, sub: string | undefined): string {
|
||||
const parts = `${base ?? ''}/${sub ?? ''}`.split('/').filter(Boolean);
|
||||
// Express ":param" → OpenAPI "{param}"
|
||||
return '/' + parts.map((p) => (p.startsWith(':') ? `{${p.slice(1)}}` : p)).join('/');
|
||||
}
|
||||
|
||||
/** Find full-body Zod schemas per route and merge them into the document. */
|
||||
export function attachZodBodySchemas(app: INestApplication, document: OpenAPIObject): void {
|
||||
const modules = app.get(ModulesContainer, { strict: false });
|
||||
for (const module of modules.values()) {
|
||||
for (const wrapper of module.controllers.values()) {
|
||||
const ctor = wrapper.metatype as (abstract new (...args: never[]) => unknown) | undefined;
|
||||
if (!ctor || typeof ctor !== 'function') continue;
|
||||
const basePath = Reflect.getMetadata(PATH_METADATA, ctor) as string | undefined;
|
||||
if (basePath === undefined) continue;
|
||||
|
||||
for (const name of Object.getOwnPropertyNames(ctor.prototype)) {
|
||||
if (name === 'constructor') continue;
|
||||
const handler = (ctor.prototype as Record<string, unknown>)[name];
|
||||
if (typeof handler !== 'function') continue;
|
||||
const methodEnum = Reflect.getMetadata(METHOD_METADATA, handler) as RequestMethod | undefined;
|
||||
const verb = methodEnum !== undefined ? HTTP_METHOD[methodEnum] : undefined;
|
||||
if (!verb) continue;
|
||||
|
||||
const args = (Reflect.getMetadata(ROUTE_ARGS_METADATA, ctor, name) ?? {}) as Record<
|
||||
string,
|
||||
{ index: number; data?: unknown; pipes?: unknown[] }
|
||||
>;
|
||||
const bodyArg = Object.entries(args).find(([key, meta]) =>
|
||||
key.startsWith(`${BODY_PARAMTYPE}:`)
|
||||
// @Body('field') picks a sub-field — only whole-body pipes describe the request body
|
||||
&& meta.data === undefined
|
||||
&& (meta.pipes ?? []).some((p) => p instanceof ZodValidationPipe),
|
||||
);
|
||||
if (!bodyArg) continue;
|
||||
const zodPipe = (bodyArg[1].pipes ?? []).find((p): p is ZodValidationPipe => p instanceof ZodValidationPipe)!;
|
||||
|
||||
const route = document.paths[joinPath(basePath, Reflect.getMetadata(PATH_METADATA, handler) as string | undefined)];
|
||||
const operation = route?.[verb as 'get' | 'post' | 'put' | 'patch' | 'delete'];
|
||||
if (!operation) continue;
|
||||
operation.requestBody = {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: zodToOpenApi(zodPipe.schema) } },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import type { ZodType } from 'zod';
|
||||
*/
|
||||
@Injectable()
|
||||
export class ZodValidationPipe implements PipeTransform {
|
||||
constructor(private readonly schema: ZodType) {}
|
||||
// Public so the API-docs enricher can lift the schema into the OpenAPI
|
||||
// document (#1412) — the pipe stays the single source of truth.
|
||||
constructor(readonly schema: ZodType) {}
|
||||
|
||||
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
|
||||
const result = this.schema.safeParse(value);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { attachZodBodySchemas } from '../common/api-zod';
|
||||
|
||||
/**
|
||||
* Swagger UI + OpenAPI spec for the REST API (#1412), gated behind
|
||||
* TREK_API_DOCS_ENABLED. Must run BEFORE app.init() — SwaggerModule.setup
|
||||
* registers Express-level routes, and a post-init Express route is
|
||||
* unreachable behind the Nest router (see bootstrap.ts).
|
||||
*
|
||||
* /api/docs Swagger UI (all controllers, try-it-out, auth button)
|
||||
* /api/docs-json raw OpenAPI 3 document (generated clients, Postman)
|
||||
* /api/docs-yaml same, as YAML
|
||||
*
|
||||
* The bearer button works with a plain TREK session JWT: extractToken
|
||||
* accepts `Authorization: Bearer` everywhere as the cookie fallback.
|
||||
*/
|
||||
export function setupApiDocs(app: INestApplication): void {
|
||||
const version: string = process.env.APP_VERSION || (require('../../../package.json') as { version: string }).version;
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('TREK API')
|
||||
.setDescription(
|
||||
'The REST API the TREK web app itself runs on. Authenticate with a session JWT — '
|
||||
+ 'either the `trek_session` cookie (same browser) or an `Authorization: Bearer <jwt>` header.',
|
||||
)
|
||||
.setVersion(version)
|
||||
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'session')
|
||||
.addCookieAuth('trek_session')
|
||||
.addSecurityRequirements('session')
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
// Lift the Zod schemas the routes already validate with into the document —
|
||||
// no double annotation, every ZodValidationPipe body is documented.
|
||||
attachZodBodySchemas(app, document);
|
||||
SwaggerModule.setup('api/docs', app, document, {
|
||||
jsonDocumentUrl: 'api/docs-json',
|
||||
yamlDocumentUrl: 'api/docs-yaml',
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* Plugin-system kill switch (#plugins). Off by default — a brand-new, high-risk
|
||||
* surface an instance owner must deliberately opt into. Lives in its own module
|
||||
* (not config.ts) so the many tests that mock config with a partial export set
|
||||
* don't have to know about it: the plugin runtime reads the env directly here.
|
||||
* Read at call time so tests and runtime env changes take effect immediately.
|
||||
* Plugin-system kill switch (#plugins). On by default — the runtime and the
|
||||
* Admin → Plugins panel are available out of the box, but installed plugins
|
||||
* still have to be activated one by one, so no third-party code runs until an
|
||||
* admin turns a specific plugin on. Set TREK_PLUGINS_ENABLED=false to switch the
|
||||
* whole system off (installed plugins stay on disk, deactivated). Lives in its
|
||||
* own module (not config.ts) so the many tests that mock config with a partial
|
||||
* export set don't have to know about it: the plugin runtime reads the env
|
||||
* directly here. Read at call time so tests and runtime env changes take effect
|
||||
* immediately.
|
||||
*/
|
||||
export function pluginsEnabled(): boolean {
|
||||
return (process.env.TREK_PLUGINS_ENABLED || '').trim().toLowerCase() === 'true';
|
||||
const v = (process.env.TREK_PLUGINS_ENABLED || '').trim().toLowerCase();
|
||||
return !['false', '0', 'off', 'no'].includes(v);
|
||||
}
|
||||
|
||||
@@ -552,11 +552,17 @@ export function getCollabFeatures() {
|
||||
|
||||
export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
|
||||
const mapping: Record<string, string> = { chat: 'collab_chat_enabled', notes: 'collab_notes_enabled', polls: 'collab_polls_enabled', whatsnext: 'collab_whatsnext_enabled' };
|
||||
const before = getCollabFeatures();
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
||||
for (const [feat, key] of Object.entries(mapping)) {
|
||||
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
|
||||
}
|
||||
return getCollabFeatures();
|
||||
const after = getCollabFeatures();
|
||||
// Collab flags gate MCP tool/resource registration, so callers must know
|
||||
// whether anything actually flipped — a no-op save must not tear down every
|
||||
// live MCP session (#1414).
|
||||
const changed = (Object.keys(after) as Array<keyof typeof after>).some(k => after[k] !== before[k]);
|
||||
return { features: after, changed };
|
||||
}
|
||||
|
||||
// ── Packing Templates ──────────────────────────────────────────────────────
|
||||
@@ -766,8 +772,19 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco
|
||||
}
|
||||
: null;
|
||||
|
||||
// Only these addons gate MCP tool/resource/prompt registration (see
|
||||
// registerTools/registerResources) — and only a real enabled-flip changes
|
||||
// what a session would register. Config-only saves, photo providers and
|
||||
// MCP-irrelevant addons must not tear down every live session (#1414).
|
||||
const MCP_RELEVANT_ADDONS = new Set<string>([
|
||||
ADDON_IDS.MCP, ADDON_IDS.PACKING, ADDON_IDS.BUDGET, ADDON_IDS.COLLAB,
|
||||
ADDON_IDS.ATLAS, ADDON_IDS.VACAY, ADDON_IDS.JOURNEY,
|
||||
]);
|
||||
const enabledChanged = !!addon && data.enabled !== undefined && (data.enabled ? 1 : 0) !== addon.enabled;
|
||||
|
||||
return {
|
||||
addon: updated,
|
||||
mcpAffected: enabledChanged && MCP_RELEVANT_ADDONS.has(id),
|
||||
auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
CollectionSaveResult,
|
||||
CollectionCopyToTripRequest,
|
||||
CollectionStatus,
|
||||
CollectionLabel,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** Links are stored as a JSON TEXT column; parse on read, stringify on write. */
|
||||
@@ -147,9 +148,32 @@ function loadTagsByCollectionPlaceIds(placeIds: number[]): Record<number, { id:
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A list's own label definitions, in display order. */
|
||||
function loadLabelsByCollection(collectionId: number): CollectionLabel[] {
|
||||
return db.prepare(
|
||||
'SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE collection_id = ? ORDER BY sort_order, id',
|
||||
).all(collectionId) as CollectionLabel[];
|
||||
}
|
||||
|
||||
/** Assigned label ids per place, batched (mirrors loadTagsByCollectionPlaceIds). */
|
||||
function loadLabelIdsByPlaceIds(placeIds: number[]): Record<number, number[]> {
|
||||
const out: Record<number, number[]> = {};
|
||||
if (placeIds.length === 0) return out;
|
||||
const placeholders = placeIds.map(() => '?').join(',');
|
||||
const rows = db.prepare(
|
||||
`SELECT collection_place_id AS pid, label_id FROM collection_place_labels WHERE collection_place_id IN (${placeholders})`,
|
||||
).all(...placeIds) as { pid: number; label_id: number }[];
|
||||
for (const r of rows) {
|
||||
if (!out[r.pid]) out[r.pid] = [];
|
||||
out[r.pid].push(r.label_id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] {
|
||||
const ids = rows.map(r => r.id);
|
||||
const tagsByPlace = loadTagsByCollectionPlaceIds(ids);
|
||||
const labelsByPlace = loadLabelIdsByPlaceIds(ids);
|
||||
return rows.map(r => {
|
||||
const { category_name, category_color, category_icon, ...rest } = r;
|
||||
return {
|
||||
@@ -159,6 +183,7 @@ function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] {
|
||||
? { id: r.category_id, name: category_name ?? '', color: category_color ?? null, icon: category_icon ?? null }
|
||||
: undefined,
|
||||
tags: tagsByPlace[r.id] || [],
|
||||
label_ids: labelsByPlace[r.id] || [],
|
||||
} as CollectionPlace;
|
||||
});
|
||||
}
|
||||
@@ -240,7 +265,10 @@ export function getCollection(userId: number, id: number): CollectionDetailRespo
|
||||
WHERE cp.collection_id = ?
|
||||
ORDER BY cp.sort_order, cp.created_at
|
||||
`).all(id) as PlaceRow[];
|
||||
return { collection: { ...collection, is_owner: collection.owner_id === userId }, places: hydratePlaces(rows) };
|
||||
return {
|
||||
collection: { ...collection, is_owner: collection.owner_id === userId, labels: loadLabelsByCollection(id) },
|
||||
places: hydratePlaces(rows),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCollection(userId: number, body: CollectionCreateRequest): Collection {
|
||||
@@ -528,6 +556,11 @@ export function updatePlace(userId: number, placeId: number, body: import('@trek
|
||||
attachTags(placeId, body.tag_ids);
|
||||
}
|
||||
|
||||
// Labels are collection-scoped: a move invalidates the source list's labels;
|
||||
// a provided label_ids set replaces them against the (target) collection.
|
||||
if (movedTo) db.prepare('DELETE FROM collection_place_labels WHERE collection_place_id = ?').run(placeId);
|
||||
if (body.label_ids !== undefined) setPlaceLabels(placeId, movedTo ?? currentCollection, body.label_ids);
|
||||
|
||||
notifyCollectionUsers(currentCollection, socketId, 'collections:updated');
|
||||
if (movedTo) notifyCollectionUsers(movedTo, socketId, 'collections:updated');
|
||||
return getPlaceById(placeId);
|
||||
@@ -677,6 +710,106 @@ export function notifyCollectionUsers(collectionId: number, excludeSid: string |
|
||||
userIds.forEach(id => broadcastToUser(id, { type: event, collectionId }, excludeSid));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Labels — per-collection custom labels. Managing + assigning both require edit
|
||||
// rights (owner/admin/editor); filtering is a read available to every member.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_LABELS_PER_COLLECTION = 50;
|
||||
|
||||
function collectionIdOfLabel(labelId: number): number {
|
||||
const row = db.prepare('SELECT collection_id FROM collection_labels WHERE id = ?').get(labelId) as { collection_id: number } | undefined;
|
||||
if (!row) httpError(404, 'Label not found');
|
||||
return row.collection_id;
|
||||
}
|
||||
|
||||
function getLabelById(labelId: number): CollectionLabel {
|
||||
return db.prepare('SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE id = ?').get(labelId) as CollectionLabel;
|
||||
}
|
||||
|
||||
/** Replace a place's label assignments, keeping only labels of `collectionId`. */
|
||||
function setPlaceLabels(placeId: number, collectionId: number, labelIds: number[]): void {
|
||||
db.prepare('DELETE FROM collection_place_labels WHERE collection_place_id = ?').run(placeId);
|
||||
if (labelIds.length === 0) return;
|
||||
const valid = new Set(loadLabelsByCollection(collectionId).map(l => l.id));
|
||||
const stmt = db.prepare('INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?, ?)');
|
||||
for (const id of labelIds) if (valid.has(id)) stmt.run(placeId, id);
|
||||
}
|
||||
|
||||
export function createLabel(userId: number, collectionId: number, name: string, color?: string, socketId?: string): CollectionLabel {
|
||||
assertCanEdit(userId, collectionId);
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) httpError(400, 'Label name is required');
|
||||
const count = (db.prepare('SELECT COUNT(*) AS n FROM collection_labels WHERE collection_id = ?').get(collectionId) as { n: number }).n;
|
||||
if (count >= MAX_LABELS_PER_COLLECTION) httpError(400, `A list can have at most ${MAX_LABELS_PER_COLLECTION} labels`);
|
||||
if (db.prepare('SELECT 1 FROM collection_labels WHERE collection_id = ? AND lower(name) = lower(?)').get(collectionId, trimmed)) {
|
||||
httpError(409, 'A label with this name already exists');
|
||||
}
|
||||
const nextSort = (db.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collection_labels WHERE collection_id = ?').get(collectionId) as { m: number }).m + 1;
|
||||
const res = db.prepare('INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?, ?, ?, ?)')
|
||||
.run(collectionId, trimmed, color ?? '#6366f1', nextSort);
|
||||
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
||||
return getLabelById(Number(res.lastInsertRowid));
|
||||
}
|
||||
|
||||
export function updateLabel(userId: number, labelId: number, body: { name?: string; color?: string; sort_order?: number }, socketId?: string): CollectionLabel {
|
||||
const collectionId = collectionIdOfLabel(labelId);
|
||||
assertCanEdit(userId, collectionId);
|
||||
const updates: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
if (body.name !== undefined) {
|
||||
const trimmed = body.name.trim();
|
||||
if (!trimmed) httpError(400, 'Label name is required');
|
||||
if (db.prepare('SELECT 1 FROM collection_labels WHERE collection_id = ? AND lower(name) = lower(?) AND id != ?').get(collectionId, trimmed, labelId)) {
|
||||
httpError(409, 'A label with this name already exists');
|
||||
}
|
||||
updates.push('name = ?'); params.push(trimmed);
|
||||
}
|
||||
if (body.color !== undefined) { updates.push('color = ?'); params.push(body.color); }
|
||||
if (body.sort_order !== undefined) { updates.push('sort_order = ?'); params.push(body.sort_order); }
|
||||
if (updates.length > 0) {
|
||||
params.push(labelId);
|
||||
db.prepare(`UPDATE collection_labels SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||
}
|
||||
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
||||
return getLabelById(labelId);
|
||||
}
|
||||
|
||||
export function deleteLabel(userId: number, labelId: number, socketId?: string): void {
|
||||
const collectionId = collectionIdOfLabel(labelId);
|
||||
assertCanEdit(userId, collectionId);
|
||||
db.prepare('DELETE FROM collection_labels WHERE id = ?').run(labelId); // CASCADE clears place assignments
|
||||
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
||||
}
|
||||
|
||||
/** Bulk add (or remove) one or more labels across a selection of places.
|
||||
* Places are grouped by list so each list is permission-checked once, and only
|
||||
* labels that belong to that list are applied. */
|
||||
export function assignLabels(userId: number, labelIds: number[], placeIds: number[], remove: boolean, socketId?: string): { changed: number } {
|
||||
const byCollection = new Map<number, number[]>();
|
||||
for (const pid of placeIds) {
|
||||
const cid = collectionIdOfPlace(pid);
|
||||
if (!byCollection.has(cid)) byCollection.set(cid, []);
|
||||
byCollection.get(cid)!.push(pid);
|
||||
}
|
||||
let changed = 0;
|
||||
for (const [cid, pids] of byCollection) {
|
||||
assertCanEdit(userId, cid);
|
||||
const valid = new Set(loadLabelsByCollection(cid).map(l => l.id));
|
||||
const applicable = labelIds.filter(id => valid.has(id));
|
||||
if (applicable.length === 0) continue;
|
||||
if (remove) {
|
||||
const del = db.prepare('DELETE FROM collection_place_labels WHERE collection_place_id = ? AND label_id = ?');
|
||||
for (const pid of pids) for (const lid of applicable) changed += del.run(pid, lid).changes;
|
||||
} else {
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?, ?)');
|
||||
for (const pid of pids) for (const lid of applicable) changed += ins.run(pid, lid).changes;
|
||||
}
|
||||
notifyCollectionUsers(cid, socketId, 'collections:updated');
|
||||
}
|
||||
return { changed };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fusion invitations (mirror vacayService, dropping the one-fusion guards)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -190,6 +190,39 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
|
||||
expect(res.status).toBe(403); // visible (member) but not owner
|
||||
});
|
||||
|
||||
// ── Labels ─────────────────────────────────────────────────────────────────
|
||||
it('COLLECTIONS-E2E-060: a label route is addon-gated (404 when disabled)', async () => {
|
||||
isAddonEnabled.mockReturnValue(false);
|
||||
expect((await request(server).post('/api/addons/collections/labels').send({ collection_id: 1, name: 'X' })).status).toBe(404);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-E2E-061: create a label, assign it to a place, read it back on the detail', async () => {
|
||||
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Germany' })).body;
|
||||
const place = (await request(server).post('/api/addons/collections/places')
|
||||
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Gate' })).body.place;
|
||||
|
||||
const label = await request(server).post('/api/addons/collections/labels')
|
||||
.set('Cookie', sessionCookie(ownerId)).set('X-Socket-Id', 'sock-2').send({ collection_id: col.id, name: 'Berlin', color: '#ff0000' });
|
||||
expect(label.status).toBe(200);
|
||||
expect(label.body.name).toBe('Berlin');
|
||||
|
||||
const assign = await request(server).post('/api/addons/collections/labels/assign')
|
||||
.set('Cookie', sessionCookie(ownerId)).send({ label_ids: [label.body.id], place_ids: [place.id] });
|
||||
expect(assign.status).toBe(200);
|
||||
expect(assign.body.changed).toBe(1);
|
||||
|
||||
const detail = await request(server).get(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(ownerId));
|
||||
expect(detail.body.collection.labels.map((l: { name: string }) => l.name)).toContain('Berlin');
|
||||
expect(detail.body.places.find((p: { id: number }) => p.id === place.id).label_ids).toContain(label.body.id);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-E2E-062: a stranger cannot create a label on someone else’s list (404)', async () => {
|
||||
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Secret' })).body;
|
||||
const res = await request(server).post('/api/addons/collections/labels')
|
||||
.set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id, name: 'Nope' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// ── delete ───────────────────────────────────────────────────────────────
|
||||
it('COLLECTIONS-E2E-050: owner deletes; non-owner member cannot (403)', async () => {
|
||||
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Doomed' })).body;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* API-DOCS (#1412) — the flag-gated Swagger surface: /api/docs (UI),
|
||||
* /api/docs-json (raw OpenAPI 3 spec incl. the Zod-derived request bodies),
|
||||
* off-by-default behaviour, and the CSP staying intact on the docs routes.
|
||||
* Boots the real buildApp() like bootstrap.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
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: () => undefined,
|
||||
isOwner: () => false,
|
||||
};
|
||||
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: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { apiDocsEnabled } from '../../src/nest/common/api-docs.kill-switch';
|
||||
|
||||
describe('API-DOCS (#1412) — flag-gated OpenAPI surface', () => {
|
||||
let app: INestApplication;
|
||||
let instance: import('express').Application;
|
||||
let prevFlag: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
resetTestDb(testDb);
|
||||
prevFlag = process.env.TREK_API_DOCS_ENABLED;
|
||||
process.env.TREK_API_DOCS_ENABLED = 'true';
|
||||
app = await buildApp();
|
||||
instance = app.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (prevFlag === undefined) delete process.env.TREK_API_DOCS_ENABLED;
|
||||
else process.env.TREK_API_DOCS_ENABLED = prevFlag;
|
||||
await app.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
it('DOCS-001 — the kill switch parses the env strictly', () => {
|
||||
const prev = process.env.TREK_API_DOCS_ENABLED;
|
||||
try {
|
||||
process.env.TREK_API_DOCS_ENABLED = 'true';
|
||||
expect(apiDocsEnabled()).toBe(true);
|
||||
process.env.TREK_API_DOCS_ENABLED = ' TRUE ';
|
||||
expect(apiDocsEnabled()).toBe(true);
|
||||
process.env.TREK_API_DOCS_ENABLED = 'false';
|
||||
expect(apiDocsEnabled()).toBe(false);
|
||||
process.env.TREK_API_DOCS_ENABLED = '1';
|
||||
expect(apiDocsEnabled()).toBe(false);
|
||||
delete process.env.TREK_API_DOCS_ENABLED;
|
||||
expect(apiDocsEnabled()).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.TREK_API_DOCS_ENABLED;
|
||||
else process.env.TREK_API_DOCS_ENABLED = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('DOCS-002 — /api/docs serves the Swagger UI with the CSP intact', async () => {
|
||||
const res = await request(instance).get('/api/docs').redirects(1);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
expect(res.headers['content-security-policy']).toBeDefined();
|
||||
});
|
||||
|
||||
it('DOCS-003 — /api/docs-json is a full OpenAPI 3 document over all controllers', async () => {
|
||||
const res = await request(instance).get('/api/docs-json');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.openapi).toMatch(/^3\./);
|
||||
// Reflection survived every controller: a few far-apart domains are present.
|
||||
expect(res.body.paths['/api/trips']).toBeDefined();
|
||||
expect(res.body.paths['/api/admin/addons/{id}']).toBeDefined();
|
||||
expect(res.body.components.securitySchemes.session).toBeDefined();
|
||||
});
|
||||
|
||||
it('DOCS-004 — Zod request bodies are lifted into the spec (no double annotation)', async () => {
|
||||
const res = await request(instance).get('/api/docs-json');
|
||||
// collections create validates with collectionCreateRequestSchema via
|
||||
// ZodValidationPipe — the enricher must surface its object schema.
|
||||
const create = res.body.paths['/api/addons/collections']?.post;
|
||||
expect(create).toBeDefined();
|
||||
const schema = create.requestBody?.content?.['application/json']?.schema;
|
||||
expect(schema?.type).toBe('object');
|
||||
expect(schema?.properties?.name).toBeDefined();
|
||||
});
|
||||
|
||||
it('DOCS-005 — without the flag the docs routes do not exist', async () => {
|
||||
const prev = process.env.TREK_API_DOCS_ENABLED;
|
||||
delete process.env.TREK_API_DOCS_ENABLED;
|
||||
let offApp: INestApplication | undefined;
|
||||
try {
|
||||
offApp = await buildApp();
|
||||
const off = offApp.getHttpAdapter().getInstance();
|
||||
expect((await request(off).get('/api/docs')).status).toBe(404);
|
||||
expect((await request(off).get('/api/docs-json')).status).toBe(404);
|
||||
} finally {
|
||||
if (offApp) await offApp.close();
|
||||
if (prev === undefined) delete process.env.TREK_API_DOCS_ENABLED;
|
||||
else process.env.TREK_API_DOCS_ENABLED = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* MCP env tuning knobs (#1414): MCP_SESSION_TTL + MCP_SSE_KEEPALIVE parsing.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveSessionTtlMs, resolveKeepaliveMs } from '../../../src/mcp/config';
|
||||
|
||||
describe('resolveSessionTtlMs', () => {
|
||||
it('defaults to 1 hour when unset or invalid', () => {
|
||||
expect(resolveSessionTtlMs(undefined)).toBe(60 * 60 * 1000);
|
||||
expect(resolveSessionTtlMs('')).toBe(60 * 60 * 1000);
|
||||
expect(resolveSessionTtlMs('nope')).toBe(60 * 60 * 1000);
|
||||
expect(resolveSessionTtlMs('0')).toBe(60 * 60 * 1000);
|
||||
expect(resolveSessionTtlMs('-5')).toBe(60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('reads seconds from MCP_SESSION_TTL', () => {
|
||||
expect(resolveSessionTtlMs('3600')).toBe(3600 * 1000);
|
||||
expect(resolveSessionTtlMs('120')).toBe(120 * 1000);
|
||||
});
|
||||
|
||||
it('clamps to 24h so a milliseconds typo cannot yield a 1000-hour session', () => {
|
||||
expect(resolveSessionTtlMs('3600000')).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveKeepaliveMs', () => {
|
||||
it('defaults to 25s when unset or invalid', () => {
|
||||
expect(resolveKeepaliveMs(undefined)).toBe(25_000);
|
||||
expect(resolveKeepaliveMs('abc')).toBe(25_000);
|
||||
expect(resolveKeepaliveMs('-1')).toBe(25_000);
|
||||
});
|
||||
|
||||
it('reads seconds and allows 0 to disable', () => {
|
||||
expect(resolveKeepaliveMs('30')).toBe(30_000);
|
||||
expect(resolveKeepaliveMs('0')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -82,11 +82,16 @@ describe('AdminController invites + feature toggles', () => {
|
||||
expect(new AdminController(svc({ updatePlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).updatePlacesPhotos(user, { enabled: true }, req)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('collab-features update invalidates MCP sessions + audits', () => {
|
||||
it('collab-features update invalidates MCP sessions only when a flag actually flipped (#1414)', () => {
|
||||
const invalidateMcpSessions = vi.fn();
|
||||
const c = new AdminController(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ chat: true }), invalidateMcpSessions } as Partial<AdminService>));
|
||||
const c = new AdminController(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ features: { chat: true }, changed: true }), invalidateMcpSessions } as Partial<AdminService>));
|
||||
expect(c.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true });
|
||||
expect(invalidateMcpSessions).toHaveBeenCalled();
|
||||
|
||||
const noopInvalidate = vi.fn();
|
||||
const noop = new AdminController(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ features: { chat: true }, changed: false }), invalidateMcpSessions: noopInvalidate } as Partial<AdminService>));
|
||||
expect(noop.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true });
|
||||
expect(noopInvalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,11 +105,17 @@ describe('AdminController packing templates', () => {
|
||||
});
|
||||
|
||||
describe('AdminController addons + sessions + jwt + defaults', () => {
|
||||
it('addon update audits + invalidates MCP sessions', () => {
|
||||
it('addon update audits + invalidates MCP sessions only when the MCP surface changed (#1414)', () => {
|
||||
const invalidateMcpSessions = vi.fn();
|
||||
const c = new AdminController(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'mcp', enabled: true }, auditDetails: {} }), invalidateMcpSessions } as Partial<AdminService>));
|
||||
const c = new AdminController(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'mcp', enabled: true }, mcpAffected: true, auditDetails: {} }), invalidateMcpSessions } as Partial<AdminService>));
|
||||
expect(c.updateAddon(user, 'mcp', { enabled: true }, req)).toEqual({ addon: { id: 'mcp', enabled: true } });
|
||||
expect(invalidateMcpSessions).toHaveBeenCalled();
|
||||
|
||||
// Config-only saves / MCP-irrelevant addons keep live sessions alive.
|
||||
const noopInvalidate = vi.fn();
|
||||
const noop = new AdminController(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'llm_parsing', enabled: true }, mcpAffected: false, auditDetails: {} }), invalidateMcpSessions: noopInvalidate } as Partial<AdminService>));
|
||||
expect(noop.updateAddon(user, 'llm_parsing', { config: { model: 'x' } }, req)).toEqual({ addon: { id: 'llm_parsing', enabled: true } });
|
||||
expect(noopInvalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('oauth-sessions revoke audits; rotate-jwt maps error', () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* zodToOpenApi (#1412): Zod → OpenAPI 3.0 conversion + the degrade path.
|
||||
* The end-to-end enricher behaviour is covered by tests/integration/api-docs.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { zodToOpenApi } from '../../../src/nest/common/api-zod';
|
||||
|
||||
describe('zodToOpenApi', () => {
|
||||
it('converts an object schema with required/optional split', () => {
|
||||
const out = zodToOpenApi(z.object({ name: z.string(), count: z.number().optional() })) as {
|
||||
type: string; properties: Record<string, unknown>; required: string[];
|
||||
};
|
||||
expect(out.type).toBe('object');
|
||||
expect(out.properties.name).toEqual({ type: 'string' });
|
||||
expect(out.properties.count).toEqual({ type: 'number' });
|
||||
expect(out.required).toEqual(['name']);
|
||||
});
|
||||
|
||||
it('survives transform-heavy schemas via unrepresentable: any', () => {
|
||||
const out = zodToOpenApi(z.object({ when: z.string().transform((s) => new Date(s)) }));
|
||||
expect((out as { type: string }).type).toBe('object');
|
||||
});
|
||||
|
||||
it('degrades to a bare object instead of throwing on a broken schema', () => {
|
||||
expect(zodToOpenApi({} as never)).toEqual({ type: 'object' });
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,10 @@ function makeService(overrides: Partial<Record<keyof CollectionsService, unknown
|
||||
leaveCollection: vi.fn(),
|
||||
removeMember: vi.fn(),
|
||||
availableUsers: vi.fn().mockReturnValue([{ id: 2 }]),
|
||||
createLabel: vi.fn().mockReturnValue({ id: 5, collection_id: 3, name: 'Berlin' }),
|
||||
updateLabel: vi.fn().mockReturnValue({ id: 5, collection_id: 3, name: 'Hamburg' }),
|
||||
deleteLabel: vi.fn(),
|
||||
assignLabels: vi.fn().mockReturnValue({ changed: 2 }),
|
||||
...overrides,
|
||||
} as unknown as CollectionsService;
|
||||
}
|
||||
@@ -191,4 +195,26 @@ describe('CollectionsController', () => {
|
||||
expect(svc.setCollectionCover).toHaveBeenCalledWith(1, 3, '/uploads/covers/x.jpg', 'sid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('labels', () => {
|
||||
it('create / update / delete / assign / unassign forward user + socket id', () => {
|
||||
const svc = makeService();
|
||||
const c = new CollectionsController(svc);
|
||||
|
||||
c.createLabel(user, { collection_id: 3, name: 'Berlin', color: '#ff0000' } as never, 'sock');
|
||||
expect(svc.createLabel).toHaveBeenCalledWith(1, 3, 'Berlin', '#ff0000', 'sock');
|
||||
|
||||
c.updateLabel(user, '5', { name: 'Hamburg' } as never, 'sock');
|
||||
expect(svc.updateLabel).toHaveBeenCalledWith(1, 5, { name: 'Hamburg' }, 'sock');
|
||||
|
||||
expect(c.deleteLabel(user, '5', 'sock')).toEqual({ success: true });
|
||||
expect(svc.deleteLabel).toHaveBeenCalledWith(1, 5, 'sock');
|
||||
|
||||
c.assignLabels(user, { label_ids: [5], place_ids: [9, 10] } as never, 'sock');
|
||||
expect(svc.assignLabels).toHaveBeenCalledWith(1, [5], [9, 10], false, 'sock');
|
||||
|
||||
c.unassignLabels(user, { label_ids: [5], place_ids: [9] } as never, 'sock');
|
||||
expect(svc.assignLabels).toHaveBeenCalledWith(1, [5], [9], true, 'sock');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,18 @@ describe('PluginsService.list', () => {
|
||||
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
|
||||
});
|
||||
|
||||
it('reports disabled when the kill switch is off (default)', () => {
|
||||
it('reports enabled by default (no kill switch set)', () => {
|
||||
testDb
|
||||
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')
|
||||
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
|
||||
|
||||
const out = new PluginsService().list();
|
||||
expect(out.enabled).toBe(true);
|
||||
expect(out.plugins).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', () => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
const out = new PluginsService().list();
|
||||
expect(out.enabled).toBe(false);
|
||||
expect(out.plugins).toEqual([]);
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
checkVersion,
|
||||
listAddons,
|
||||
updateAddon,
|
||||
updateCollabFeatures,
|
||||
listMcpTokens,
|
||||
deleteMcpToken,
|
||||
} from '../../../src/services/adminService';
|
||||
@@ -678,6 +679,33 @@ describe('updateAddon', () => {
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-069 — mcpAffected only fires on a real enabled-flip of an MCP-relevant addon (#1414)', () => {
|
||||
updateAddon('packing', { enabled: true });
|
||||
// no-op save (enabled already true) → sessions survive
|
||||
expect((updateAddon('packing', { enabled: true }) as any).mcpAffected).toBe(false);
|
||||
// config-only save → sessions survive
|
||||
expect((updateAddon('packing', { config: { foo: 'bar' } }) as any).mcpAffected).toBe(false);
|
||||
// real flip of an MCP-relevant addon → invalidate
|
||||
expect((updateAddon('packing', { enabled: false }) as any).mcpAffected).toBe(true);
|
||||
expect((updateAddon('packing', { enabled: true }) as any).mcpAffected).toBe(true);
|
||||
// real flip of an addon with no MCP surface → sessions survive
|
||||
const docsFlip = updateAddon('documents', { enabled: false }) as any;
|
||||
if (!docsFlip.error) expect(docsFlip.mcpAffected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCollabFeatures', () => {
|
||||
it('ADMIN-SVC-070 — reports whether a flag actually flipped (#1414)', () => {
|
||||
const first = updateCollabFeatures({ chat: false });
|
||||
expect(first.changed).toBe(true);
|
||||
expect(first.features.chat).toBe(false);
|
||||
// identical save → no change, MCP sessions must survive
|
||||
const second = updateCollabFeatures({ chat: false });
|
||||
expect(second.changed).toBe(false);
|
||||
const third = updateCollabFeatures({ chat: true });
|
||||
expect(third.changed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── MCP Tokens ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,6 +47,8 @@ import { removeIfUnreferenced } from '../../../src/services/placePhotoCache';
|
||||
|
||||
function clearCollections() {
|
||||
testDb.exec(`
|
||||
DELETE FROM collection_place_labels;
|
||||
DELETE FROM collection_labels;
|
||||
DELETE FROM collection_place_tags;
|
||||
DELETE FROM collection_places;
|
||||
DELETE FROM collection_members;
|
||||
@@ -482,3 +484,87 @@ describe('photo-cache widening', () => {
|
||||
expect(meta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Labels ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function addMember(colId: number, userId: number, role: 'viewer' | 'editor' | 'admin') {
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', ?)").run(colId, userId, role);
|
||||
}
|
||||
|
||||
describe('collection labels', () => {
|
||||
it('COLLECTIONS-SVC-050: createLabel is returned by getCollection; duplicate name is 409', () => {
|
||||
const u = createUser(testDb).user;
|
||||
const col = svc.createCollection(u.id, { name: 'Germany' });
|
||||
const label = svc.createLabel(u.id, col.id, 'Berlin', '#ff0000');
|
||||
expect(label.name).toBe('Berlin');
|
||||
expect(label.collection_id).toBe(col.id);
|
||||
expect(svc.getCollection(u.id, col.id).collection.labels).toHaveLength(1);
|
||||
|
||||
expect(() => svc.createLabel(u.id, col.id, 'berlin')).toThrow(); // case-insensitive dup
|
||||
try { svc.createLabel(u.id, col.id, 'berlin'); } catch (e) { expect((e as { status: number }).status).toBe(409); }
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-051: a viewer cannot manage labels (403); an editor can', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const viewer = createUser(testDb).user;
|
||||
const col = svc.createCollection(owner.id, { name: 'Trip' });
|
||||
addMember(col.id, viewer.id, 'viewer');
|
||||
try { svc.createLabel(viewer.id, col.id, 'X'); } catch (e) { expect((e as { status: number }).status).toBe(403); }
|
||||
|
||||
const editor = createUser(testDb).user;
|
||||
addMember(col.id, editor.id, 'editor');
|
||||
expect(svc.createLabel(editor.id, col.id, 'Museums').id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-052: updatePlace label_ids sets labels; a label from another list is ignored', () => {
|
||||
const u = createUser(testDb).user;
|
||||
const col = svc.createCollection(u.id, { name: 'DE' });
|
||||
const other = svc.createCollection(u.id, { name: 'Other' });
|
||||
const l1 = svc.createLabel(u.id, col.id, 'Berlin');
|
||||
const foreign = svc.createLabel(u.id, other.id, 'Paris');
|
||||
const place = svc.savePlace(u.id, { collection_id: col.id, name: 'Gate' }).place!;
|
||||
svc.updatePlace(u.id, place.id, { label_ids: [l1.id, foreign.id] });
|
||||
const stored = svc.getCollection(u.id, col.id).places.find(p => p.id === place.id)!;
|
||||
expect(stored.label_ids).toEqual([l1.id]);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-053: assignLabels bulk-adds then unassigns across places', () => {
|
||||
const u = createUser(testDb).user;
|
||||
const col = svc.createCollection(u.id, { name: 'DE' });
|
||||
const l = svc.createLabel(u.id, col.id, 'Coast');
|
||||
const p1 = svc.savePlace(u.id, { collection_id: col.id, name: 'A' }).place!;
|
||||
const p2 = svc.savePlace(u.id, { collection_id: col.id, name: 'B' }).place!;
|
||||
|
||||
expect(svc.assignLabels(u.id, [l.id], [p1.id, p2.id], false).changed).toBe(2);
|
||||
expect(svc.getCollection(u.id, col.id).places.every(p => p.label_ids?.includes(l.id))).toBe(true);
|
||||
|
||||
svc.assignLabels(u.id, [l.id], [p1.id], true);
|
||||
const after = svc.getCollection(u.id, col.id).places;
|
||||
expect(after.find(p => p.id === p1.id)!.label_ids).toEqual([]);
|
||||
expect(after.find(p => p.id === p2.id)!.label_ids).toEqual([l.id]);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-054: deleteLabel removes it and cascades its place assignments', () => {
|
||||
const u = createUser(testDb).user;
|
||||
const col = svc.createCollection(u.id, { name: 'DE' });
|
||||
const l = svc.createLabel(u.id, col.id, 'Berlin');
|
||||
const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Gate' }).place!;
|
||||
svc.updatePlace(u.id, p.id, { label_ids: [l.id] });
|
||||
|
||||
svc.deleteLabel(u.id, l.id);
|
||||
expect(svc.getCollection(u.id, col.id).collection.labels).toHaveLength(0);
|
||||
expect(svc.getCollection(u.id, col.id).places.find(x => x.id === p.id)!.label_ids).toEqual([]);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-055: moving a place to another list drops its labels', () => {
|
||||
const u = createUser(testDb).user;
|
||||
const a = svc.createCollection(u.id, { name: 'A' });
|
||||
const b = svc.createCollection(u.id, { name: 'B' });
|
||||
const l = svc.createLabel(u.id, a.id, 'Berlin');
|
||||
const p = svc.savePlace(u.id, { collection_id: a.id, name: 'Gate' }).place!;
|
||||
svc.updatePlace(u.id, p.id, { label_ids: [l.id] });
|
||||
|
||||
svc.updatePlace(u.id, p.id, { collection_id: b.id });
|
||||
expect(svc.getCollection(u.id, b.id).places.find(x => x.id === p.id)!.label_ids).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/shared",
|
||||
"version": "3.1.3",
|
||||
"version": "3.1.4",
|
||||
"private": true,
|
||||
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
|
||||
"type": "module",
|
||||
|
||||
@@ -22,6 +22,17 @@ export const collectionLinkSchema = z.object({
|
||||
export type CollectionLink = z.infer<typeof collectionLinkSchema>;
|
||||
export const collectionLinksSchema = z.array(collectionLinkSchema).max(30);
|
||||
|
||||
/** A custom label defined per-collection (distinct from the instance-wide tags).
|
||||
* Members group and filter a list's places by these. */
|
||||
export const collectionLabelSchema = z.object({
|
||||
id: z.number(),
|
||||
collection_id: z.number(),
|
||||
name: z.string(),
|
||||
color: z.string().nullable().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
});
|
||||
export type CollectionLabel = z.infer<typeof collectionLabelSchema>;
|
||||
|
||||
/** A saved place — assignmentPlace minus itinerary, plus status + provenance. */
|
||||
export const collectionPlaceSchema = z.object({
|
||||
id: z.number(),
|
||||
@@ -52,6 +63,8 @@ export const collectionPlaceSchema = z.object({
|
||||
links: collectionLinksSchema.optional(),
|
||||
category: placeCategorySchema.optional(),
|
||||
tags: z.array(tagSchema.partial()).optional(),
|
||||
/** Ids of the per-collection labels assigned to this place. */
|
||||
label_ids: z.array(z.number()).optional(),
|
||||
});
|
||||
export type CollectionPlace = z.infer<typeof collectionPlaceSchema>;
|
||||
|
||||
@@ -81,6 +94,7 @@ export const collectionSchema = z.object({
|
||||
place_count: z.number().optional(),
|
||||
is_owner: z.boolean().optional(),
|
||||
members: z.array(collectionMemberSchema).optional(),
|
||||
labels: z.array(collectionLabelSchema).optional(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
});
|
||||
@@ -156,6 +170,8 @@ export const collectionPlaceUpdateRequestSchema = z.object({
|
||||
collection_id: z.number().optional(), // move to another list
|
||||
links: collectionLinksSchema.optional(),
|
||||
tag_ids: z.array(z.number()).optional(),
|
||||
// Replace the place's per-collection label assignments (omit to leave unchanged).
|
||||
label_ids: z.array(z.number()).optional(),
|
||||
});
|
||||
export type CollectionPlaceUpdateRequest = z.infer<typeof collectionPlaceUpdateRequestSchema>;
|
||||
|
||||
@@ -202,6 +218,32 @@ export const collectionSetMemberRoleRequestSchema = z.object({
|
||||
});
|
||||
export type CollectionSetMemberRoleRequest = z.infer<typeof collectionSetMemberRoleRequestSchema>;
|
||||
|
||||
// ── Labels ──────────────────────────────────────────────────────────────────
|
||||
const labelColorSchema = z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
|
||||
|
||||
/** Create a custom label in a list. */
|
||||
export const collectionLabelCreateRequestSchema = z.object({
|
||||
collection_id: z.number(),
|
||||
name: z.string().trim().min(1).max(60),
|
||||
color: labelColorSchema.optional(),
|
||||
});
|
||||
export type CollectionLabelCreateRequest = z.infer<typeof collectionLabelCreateRequestSchema>;
|
||||
|
||||
/** Rename / recolor a label (all fields optional). */
|
||||
export const collectionLabelUpdateRequestSchema = z.object({
|
||||
name: z.string().trim().min(1).max(60).optional(),
|
||||
color: labelColorSchema.optional(),
|
||||
sort_order: z.number().optional(),
|
||||
});
|
||||
export type CollectionLabelUpdateRequest = z.infer<typeof collectionLabelUpdateRequestSchema>;
|
||||
|
||||
/** Bulk add or remove one/several labels across many selected places. */
|
||||
export const collectionLabelAssignRequestSchema = z.object({
|
||||
label_ids: z.array(z.number()).min(1).max(50),
|
||||
place_ids: z.array(z.number()).min(1).max(1000),
|
||||
});
|
||||
export type CollectionLabelAssignRequest = z.infer<typeof collectionLabelAssignRequestSchema>;
|
||||
|
||||
// ── Responses ─────────────────────────────────────────────────────────────
|
||||
export const collectionListResponseSchema = z.object({
|
||||
collections: z.array(collectionSchema),
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'المجموعات',
|
||||
'collections.widget.empty': 'لا توجد أماكن محفوظة بعد',
|
||||
'collections.widget.savedCount': '{count} محفوظة',
|
||||
'collections.labels.title': 'التسميات',
|
||||
'collections.labels.manage': 'إدارة التسميات',
|
||||
'collections.labels.add': 'إضافة تسمية',
|
||||
'collections.labels.name': 'اسم التسمية',
|
||||
'collections.labels.namePlaceholder': 'مثال: برلين',
|
||||
'collections.labels.assign': 'تعيين تسمية',
|
||||
'collections.labels.assignN': 'إضافة تسميات إلى {count} أماكن',
|
||||
'collections.labels.assignedCount': 'تمت تسمية {count} أماكن',
|
||||
'collections.labels.empty': 'لا توجد تسميات بعد',
|
||||
'collections.labels.emptyHint': 'أنشئ تسمية أولًا لتجميع الأماكن في هذه القائمة.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Coleções',
|
||||
'collections.widget.empty': 'Nenhum lugar salvo ainda',
|
||||
'collections.widget.savedCount': '{count} salvos',
|
||||
'collections.labels.title': 'Rótulos',
|
||||
'collections.labels.manage': 'Gerenciar rótulos',
|
||||
'collections.labels.add': 'Adicionar rótulo',
|
||||
'collections.labels.name': 'Nome do rótulo',
|
||||
'collections.labels.namePlaceholder': 'ex.: Berlin',
|
||||
'collections.labels.assign': 'Atribuir rótulo',
|
||||
'collections.labels.assignN': 'Adicionar rótulos a {count} lugares',
|
||||
'collections.labels.assignedCount': '{count} lugares rotulados',
|
||||
'collections.labels.empty': 'Nenhum rótulo ainda',
|
||||
'collections.labels.emptyHint': 'Crie um rótulo primeiro para agrupar lugares nesta lista.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Sbírky',
|
||||
'collections.widget.empty': 'Zatím žádná uložená místa',
|
||||
'collections.widget.savedCount': 'Uloženo: {count}',
|
||||
'collections.labels.title': 'Štítky',
|
||||
'collections.labels.manage': 'Spravovat štítky',
|
||||
'collections.labels.add': 'Přidat štítek',
|
||||
'collections.labels.name': 'Název štítku',
|
||||
'collections.labels.namePlaceholder': 'např. Berlin',
|
||||
'collections.labels.assign': 'Přiřadit štítek',
|
||||
'collections.labels.assignN': 'Přidat štítky k {count} místům',
|
||||
'collections.labels.assignedCount': 'Označeno {count} míst',
|
||||
'collections.labels.empty': 'Zatím žádné štítky',
|
||||
'collections.labels.emptyHint': 'Nejprve vytvořte štítek, abyste mohli seskupit místa v tomto seznamu.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Sammlungen',
|
||||
'collections.widget.empty': 'Noch keine gespeicherten Orte',
|
||||
'collections.widget.savedCount': '{count} gespeichert',
|
||||
'collections.labels.title': 'Labels',
|
||||
'collections.labels.manage': 'Labels verwalten',
|
||||
'collections.labels.add': 'Label hinzufügen',
|
||||
'collections.labels.name': 'Labelname',
|
||||
'collections.labels.namePlaceholder': 'z. B. Berlin',
|
||||
'collections.labels.assign': 'Label zuweisen',
|
||||
'collections.labels.assignN': 'Labels zu {count} Orten hinzufügen',
|
||||
'collections.labels.assignedCount': '{count} Orte gelabelt',
|
||||
'collections.labels.empty': 'Noch keine Labels',
|
||||
'collections.labels.emptyHint': 'Erstelle zuerst ein Label, um Orte in dieser Liste zu gruppieren.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,17 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Collections',
|
||||
'collections.widget.empty': 'No saved places yet',
|
||||
'collections.widget.savedCount': '{count} saved',
|
||||
|
||||
'collections.labels.title': 'Labels',
|
||||
'collections.labels.manage': 'Manage labels',
|
||||
'collections.labels.add': 'Add label',
|
||||
'collections.labels.name': 'Label name',
|
||||
'collections.labels.namePlaceholder': 'e.g. Berlin',
|
||||
'collections.labels.assign': 'Assign label',
|
||||
'collections.labels.assignN': 'Add labels to {count} places',
|
||||
'collections.labels.assignedCount': 'Labelled {count} places',
|
||||
'collections.labels.empty': 'No labels yet',
|
||||
'collections.labels.emptyHint': 'Create a label first to group places in this list.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Colecciones',
|
||||
'collections.widget.empty': 'Aún no hay lugares guardados',
|
||||
'collections.widget.savedCount': '{count} guardados',
|
||||
'collections.labels.title': 'Etiquetas',
|
||||
'collections.labels.manage': 'Gestionar etiquetas',
|
||||
'collections.labels.add': 'Añadir etiqueta',
|
||||
'collections.labels.name': 'Nombre de la etiqueta',
|
||||
'collections.labels.namePlaceholder': 'p. ej. Berlin',
|
||||
'collections.labels.assign': 'Asignar etiqueta',
|
||||
'collections.labels.assignN': 'Añadir etiquetas a {count} lugares',
|
||||
'collections.labels.assignedCount': 'Etiquetados {count} lugares',
|
||||
'collections.labels.empty': 'Aún no hay etiquetas',
|
||||
'collections.labels.emptyHint': 'Crea primero una etiqueta para agrupar lugares en esta lista.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Collections',
|
||||
'collections.widget.empty': 'Aucun lieu enregistré pour le moment',
|
||||
'collections.widget.savedCount': '{count} enregistrés',
|
||||
'collections.labels.title': 'Libellés',
|
||||
'collections.labels.manage': 'Gérer les libellés',
|
||||
'collections.labels.add': 'Ajouter un libellé',
|
||||
'collections.labels.name': 'Nom du libellé',
|
||||
'collections.labels.namePlaceholder': 'p. ex. Berlin',
|
||||
'collections.labels.assign': 'Attribuer un libellé',
|
||||
'collections.labels.assignN': 'Ajouter des libellés à {count} lieux',
|
||||
'collections.labels.assignedCount': 'Libellés ajoutés à {count} lieux',
|
||||
'collections.labels.empty': 'Aucun libellé pour le moment',
|
||||
'collections.labels.emptyHint': 'Créez d’abord un libellé pour regrouper les lieux de cette liste.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,17 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Συλλογές',
|
||||
'collections.widget.empty': 'Δεν υπάρχουν αποθηκευμένα μέρη ακόμα',
|
||||
'collections.widget.savedCount': '{count} αποθηκευμένα',
|
||||
|
||||
'collections.labels.title': 'Ετικέτες',
|
||||
'collections.labels.manage': 'Διαχείριση ετικετών',
|
||||
'collections.labels.add': 'Προσθήκη ετικέτας',
|
||||
'collections.labels.name': 'Όνομα ετικέτας',
|
||||
'collections.labels.namePlaceholder': 'π.χ. Berlin',
|
||||
'collections.labels.assign': 'Αντιστοίχιση ετικέτας',
|
||||
'collections.labels.assignN': 'Προσθήκη ετικετών σε {count} μέρη',
|
||||
'collections.labels.assignedCount': 'Προστέθηκαν ετικέτες σε {count} μέρη',
|
||||
'collections.labels.empty': 'Δεν υπάρχουν ετικέτες ακόμα',
|
||||
'collections.labels.emptyHint': 'Δημιούργησε πρώτα μια ετικέτα για να ομαδοποιήσεις μέρη σε αυτή τη λίστα.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Gyűjtemények',
|
||||
'collections.widget.empty': 'Még nincsenek mentett helyek',
|
||||
'collections.widget.savedCount': '{count} mentve',
|
||||
'collections.labels.title': 'Címkék',
|
||||
'collections.labels.manage': 'Címkék kezelése',
|
||||
'collections.labels.add': 'Címke hozzáadása',
|
||||
'collections.labels.name': 'Címke neve',
|
||||
'collections.labels.namePlaceholder': 'pl. Berlin',
|
||||
'collections.labels.assign': 'Címke hozzárendelése',
|
||||
'collections.labels.assignN': 'Címkék hozzáadása {count} helyhez',
|
||||
'collections.labels.assignedCount': '{count} hely felcímkézve',
|
||||
'collections.labels.empty': 'Még nincsenek címkék',
|
||||
'collections.labels.emptyHint': 'Előbb hozz létre egy címkét a helyek csoportosításához ebben a listában.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Koleksi',
|
||||
'collections.widget.empty': 'Belum ada tempat tersimpan',
|
||||
'collections.widget.savedCount': '{count} tersimpan',
|
||||
'collections.labels.title': 'Label',
|
||||
'collections.labels.manage': 'Kelola label',
|
||||
'collections.labels.add': 'Tambah label',
|
||||
'collections.labels.name': 'Nama label',
|
||||
'collections.labels.namePlaceholder': 'mis. Berlin',
|
||||
'collections.labels.assign': 'Beri label',
|
||||
'collections.labels.assignN': 'Beri label ke {count} tempat',
|
||||
'collections.labels.assignedCount': '{count} tempat diberi label',
|
||||
'collections.labels.empty': 'Belum ada label',
|
||||
'collections.labels.emptyHint': 'Buat label dulu untuk mengelompokkan tempat di daftar ini.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,17 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Raccolte',
|
||||
'collections.widget.empty': 'Ancora nessun luogo salvato',
|
||||
'collections.widget.savedCount': '{count} salvati',
|
||||
|
||||
'collections.labels.title': 'Etichette',
|
||||
'collections.labels.manage': 'Gestisci etichette',
|
||||
'collections.labels.add': 'Aggiungi etichetta',
|
||||
'collections.labels.name': 'Nome etichetta',
|
||||
'collections.labels.namePlaceholder': 'es. Berlin',
|
||||
'collections.labels.assign': 'Assegna etichetta',
|
||||
'collections.labels.assignN': 'Aggiungi etichette a {count} luoghi',
|
||||
'collections.labels.assignedCount': 'Etichettati {count} luoghi',
|
||||
'collections.labels.empty': 'Ancora nessuna etichetta',
|
||||
'collections.labels.emptyHint': 'Crea prima un\'etichetta per raggruppare i luoghi in questa lista.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'コレクション',
|
||||
'collections.widget.empty': 'まだ保存した場所がありません',
|
||||
'collections.widget.savedCount': '{count}件保存済み',
|
||||
'collections.labels.title': 'ラベル',
|
||||
'collections.labels.manage': 'ラベルを管理',
|
||||
'collections.labels.add': 'ラベルを追加',
|
||||
'collections.labels.name': 'ラベル名',
|
||||
'collections.labels.namePlaceholder': '例: Berlin',
|
||||
'collections.labels.assign': 'ラベルを割り当て',
|
||||
'collections.labels.assignN': '{count} 件の場所にラベルを追加',
|
||||
'collections.labels.assignedCount': '{count} 件の場所にラベルを付けました',
|
||||
'collections.labels.empty': 'ラベルがまだありません',
|
||||
'collections.labels.emptyHint': 'まずラベルを作成して、このリスト内の場所をグループ化しましょう。',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': '컬렉션',
|
||||
'collections.widget.empty': '아직 저장한 장소가 없습니다',
|
||||
'collections.widget.savedCount': '{count}개 저장됨',
|
||||
'collections.labels.title': '레이블',
|
||||
'collections.labels.manage': '레이블 관리',
|
||||
'collections.labels.add': '레이블 추가',
|
||||
'collections.labels.name': '레이블 이름',
|
||||
'collections.labels.namePlaceholder': '예: Berlin',
|
||||
'collections.labels.assign': '레이블 지정',
|
||||
'collections.labels.assignN': '{count}개 장소에 레이블 추가',
|
||||
'collections.labels.assignedCount': '{count}개 장소에 레이블 지정됨',
|
||||
'collections.labels.empty': '아직 레이블이 없습니다',
|
||||
'collections.labels.emptyHint': '먼저 레이블을 만들어 이 목록의 장소를 그룹화하세요.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Collecties',
|
||||
'collections.widget.empty': 'Nog geen opgeslagen plekken',
|
||||
'collections.widget.savedCount': '{count} opgeslagen',
|
||||
'collections.labels.title': 'Labels',
|
||||
'collections.labels.manage': 'Labels beheren',
|
||||
'collections.labels.add': 'Label toevoegen',
|
||||
'collections.labels.name': 'Labelnaam',
|
||||
'collections.labels.namePlaceholder': 'bijv. Berlin',
|
||||
'collections.labels.assign': 'Label toewijzen',
|
||||
'collections.labels.assignN': 'Labels toevoegen aan {count} plaatsen',
|
||||
'collections.labels.assignedCount': '{count} plaatsen gelabeld',
|
||||
'collections.labels.empty': 'Nog geen labels',
|
||||
'collections.labels.emptyHint': 'Maak eerst een label om plekken in deze lijst te groeperen.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Kolekcje',
|
||||
'collections.widget.empty': 'Brak zapisanych miejsc',
|
||||
'collections.widget.savedCount': '{count} zapisanych',
|
||||
'collections.labels.title': 'Etykiety',
|
||||
'collections.labels.manage': 'Zarządzaj etykietami',
|
||||
'collections.labels.add': 'Dodaj etykietę',
|
||||
'collections.labels.name': 'Nazwa etykiety',
|
||||
'collections.labels.namePlaceholder': 'np. Berlin',
|
||||
'collections.labels.assign': 'Przypisz etykietę',
|
||||
'collections.labels.assignN': 'Dodaj etykiety do {count} miejsc',
|
||||
'collections.labels.assignedCount': 'Oznaczono {count} miejsc',
|
||||
'collections.labels.empty': 'Brak etykiet',
|
||||
'collections.labels.emptyHint': 'Najpierw utwórz etykietę, aby pogrupować miejsca na tej liście.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Коллекции',
|
||||
'collections.widget.empty': 'Пока нет сохранённых мест',
|
||||
'collections.widget.savedCount': 'Сохранено: {count}',
|
||||
'collections.labels.title': 'Метки',
|
||||
'collections.labels.manage': 'Управление метками',
|
||||
'collections.labels.add': 'Добавить метку',
|
||||
'collections.labels.name': 'Название метки',
|
||||
'collections.labels.namePlaceholder': 'например, Berlin',
|
||||
'collections.labels.assign': 'Назначить метку',
|
||||
'collections.labels.assignN': 'Добавить метки к {count} местам',
|
||||
'collections.labels.assignedCount': 'Отмечено мест: {count}',
|
||||
'collections.labels.empty': 'Пока нет меток',
|
||||
'collections.labels.emptyHint': 'Сначала создайте метку, чтобы группировать места в этом списке.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Samlingar',
|
||||
'collections.widget.empty': 'Inga sparade platser ännu',
|
||||
'collections.widget.savedCount': '{count} sparade',
|
||||
'collections.labels.title': 'Etiketter',
|
||||
'collections.labels.manage': 'Hantera etiketter',
|
||||
'collections.labels.add': 'Lägg till etikett',
|
||||
'collections.labels.name': 'Etikettnamn',
|
||||
'collections.labels.namePlaceholder': 't.ex. Berlin',
|
||||
'collections.labels.assign': 'Tilldela etikett',
|
||||
'collections.labels.assignN': 'Lägg till etiketter på {count} platser',
|
||||
'collections.labels.assignedCount': 'Etiketterade {count} platser',
|
||||
'collections.labels.empty': 'Inga etiketter ännu',
|
||||
'collections.labels.emptyHint': 'Skapa en etikett först för att gruppera platser i den här listan.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Koleksiyonlar',
|
||||
'collections.widget.empty': 'Henüz kaydedilmiş yer yok',
|
||||
'collections.widget.savedCount': '{count} kayıtlı',
|
||||
'collections.labels.title': 'Etiketler',
|
||||
'collections.labels.manage': 'Etiketleri yönet',
|
||||
'collections.labels.add': 'Etiket ekle',
|
||||
'collections.labels.name': 'Etiket adı',
|
||||
'collections.labels.namePlaceholder': 'örn. Berlin',
|
||||
'collections.labels.assign': 'Etiket ata',
|
||||
'collections.labels.assignN': '{count} yere etiket ekle',
|
||||
'collections.labels.assignedCount': '{count} yer etiketlendi',
|
||||
'collections.labels.empty': 'Henüz etiket yok',
|
||||
'collections.labels.emptyHint': 'Bu listedeki yerleri gruplamak için önce bir etiket oluştur.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Колекції',
|
||||
'collections.widget.empty': 'Поки немає збережених місць',
|
||||
'collections.widget.savedCount': 'Збережено: {count}',
|
||||
'collections.labels.title': 'Мітки',
|
||||
'collections.labels.manage': 'Керувати мітками',
|
||||
'collections.labels.add': 'Додати мітку',
|
||||
'collections.labels.name': 'Назва мітки',
|
||||
'collections.labels.namePlaceholder': 'напр., Berlin',
|
||||
'collections.labels.assign': 'Призначити мітку',
|
||||
'collections.labels.assignN': 'Додати мітки до {count} місць',
|
||||
'collections.labels.assignedCount': 'Позначено місць: {count}',
|
||||
'collections.labels.empty': 'Поки немає міток',
|
||||
'collections.labels.emptyHint': 'Спершу створіть мітку, щоб групувати місця в цьому списку.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'Thông báo',
|
||||
'admin.notifications.hint': 'Chọn một kênh thông báo. Chỉ có một người có thể hoạt động tại một thời điểm.',
|
||||
'admin.notifications.none': 'Tàn tật',
|
||||
'admin.notifications.none': 'Tắt',
|
||||
'admin.notifications.email': 'Email (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
@@ -357,7 +357,7 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.subtitleBefore': 'Bật hoặc tắt các tính năng để tùy chỉnh',
|
||||
'admin.addons.subtitleAfter': 'kinh nghiệm.',
|
||||
'admin.addons.enabled': 'Đã bật',
|
||||
'admin.addons.disabled': 'Tàn tật',
|
||||
'admin.addons.disabled': 'Tắt',
|
||||
'admin.addons.type.trip': 'Chuyến đi',
|
||||
'admin.addons.type.global': 'Toàn cầu',
|
||||
'admin.addons.type.integration': 'Tích hợp',
|
||||
|
||||
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': 'Bộ sưu tập',
|
||||
'collections.widget.empty': 'Chưa có địa điểm đã lưu',
|
||||
'collections.widget.savedCount': 'Đã lưu {count}',
|
||||
'collections.labels.title': 'Nhãn',
|
||||
'collections.labels.manage': 'Quản lý nhãn',
|
||||
'collections.labels.add': 'Thêm nhãn',
|
||||
'collections.labels.name': 'Tên nhãn',
|
||||
'collections.labels.namePlaceholder': 'vd. Berlin',
|
||||
'collections.labels.assign': 'Gán nhãn',
|
||||
'collections.labels.assignN': 'Thêm nhãn cho {count} địa điểm',
|
||||
'collections.labels.assignedCount': 'Đã gán nhãn {count} địa điểm',
|
||||
'collections.labels.empty': 'Chưa có nhãn nào',
|
||||
'collections.labels.emptyHint': 'Tạo một nhãn trước để nhóm các địa điểm trong danh sách này.',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,17 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': '收藏',
|
||||
'collections.widget.empty': '尚無已儲存的地點',
|
||||
'collections.widget.savedCount': '已儲存 {count} 個',
|
||||
|
||||
'collections.labels.title': '標籤',
|
||||
'collections.labels.manage': '管理標籤',
|
||||
'collections.labels.add': '新增標籤',
|
||||
'collections.labels.name': '標籤名稱',
|
||||
'collections.labels.namePlaceholder': '例如:柏林',
|
||||
'collections.labels.assign': '指派標籤',
|
||||
'collections.labels.assignN': '為 {count} 個地點新增標籤',
|
||||
'collections.labels.assignedCount': '已為 {count} 個地點加上標籤',
|
||||
'collections.labels.empty': '尚無標籤',
|
||||
'collections.labels.emptyHint': '請先建立標籤,以將此清單中的地點分組。',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -144,6 +144,17 @@ const collection: TranslationStrings = {
|
||||
'collections.widget.title': '收藏',
|
||||
'collections.widget.empty': '还没有保存的地点',
|
||||
'collections.widget.savedCount': '已保存 {count} 个',
|
||||
|
||||
'collections.labels.title': '标签',
|
||||
'collections.labels.manage': '管理标签',
|
||||
'collections.labels.add': '添加标签',
|
||||
'collections.labels.name': '标签名称',
|
||||
'collections.labels.namePlaceholder': '例如:Berlin',
|
||||
'collections.labels.assign': '分配标签',
|
||||
'collections.labels.assignN': '为 {count} 个地点添加标签',
|
||||
'collections.labels.assignedCount': '已为 {count} 个地点添加标签',
|
||||
'collections.labels.empty': '还没有标签',
|
||||
'collections.labels.emptyHint': '请先创建一个标签来对此列表中的地点分组。',
|
||||
};
|
||||
|
||||
export default collection;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# AI Booking Import
|
||||
|
||||
The **AI Parsing** addon adds a large-language-model fallback to TREK's booking import. When [KDE Itinerary](Reservations-and-Bookings#import-from-booking-confirmation) can't read a confirmation — a plain-text email, an unusual PDF layout, a vendor whose format it doesn't recognise — TREK can hand the document to an AI model and turn it into a reservation you review before saving.
|
||||
|
||||
It is an **opt-in addon, disabled by default**, and it works with a self-hosted local model, so no booking data has to leave your server.
|
||||
|
||||
> **Admin:** Enable **AI Parsing** in [Admin-Addons](Admin-Addons) (it sits in the *Integration* group). Booking import itself still requires the `kitinerary-extractor` binary — see [Reservations-and-Bookings](Reservations-and-Bookings#when-the-button-is-not-visible).
|
||||
|
||||
## How it fits with normal import
|
||||
|
||||
AI parsing does not replace [KDE Itinerary](Reservations-and-Bookings) — it backs it up:
|
||||
|
||||
1. Every uploaded file is parsed by KDE Itinerary first.
|
||||
2. Only files that Itinerary returns **nothing** for are sent to the AI model.
|
||||
3. Every reservation the AI produces is flagged **Review** so you can confirm it before (or after) saving.
|
||||
|
||||
So structured tickets keep being parsed the fast, deterministic way; the AI only steps in for the documents that would otherwise fail. If the addon is disabled, import behaves exactly as before.
|
||||
|
||||
## Choosing a provider
|
||||
|
||||
The addon supports three providers:
|
||||
|
||||
| Provider | Runs where | Notes |
|
||||
|----------|-----------|-------|
|
||||
| **Local (Ollama)** | Your own hardware | No booking data leaves your network. Recommended for privacy; works on CPU. |
|
||||
| **OpenAI** | OpenAI's API, or any **OpenAI-compatible** endpoint via a custom base URL | Needs an API key. |
|
||||
| **Anthropic** | Anthropic's API | Needs an API key. **Reads PDFs — including scans — natively.** |
|
||||
|
||||
> **Scanned PDFs:** Local and OpenAI-compatible models receive the document's *extracted text*. A scanned or image-only PDF has no text layer, so those providers return nothing for it. Only **Anthropic** ingests the raw PDF and can read scans.
|
||||
|
||||
## Admin: instance-wide configuration
|
||||
|
||||
When you enable the addon, a configuration panel appears directly under it in [Admin-Addons](Admin-Addons):
|
||||
|
||||
> *Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.*
|
||||
|
||||
- **Provider** — Local · OpenAI-compatible, OpenAI, or Anthropic.
|
||||
- **Base URL** — shown for every provider except Anthropic. Defaults to `http://localhost:11434/v1` for a local Ollama server, or `https://api.openai.com/v1` for OpenAI. Point it at any OpenAI-compatible endpoint here.
|
||||
- **API key** — optional for a local server (`(often not required)`), required for the cloud providers. Stored **encrypted**; it is shown masked (`••••••••`) once saved, and leaving it unchanged keeps the stored key.
|
||||
- **Model** — the model id (e.g. `qwen3:8b`, `gpt-4o`, `claude-opus-4-8`).
|
||||
|
||||
If you set a provider and model here, it applies to **all users** and overrides their personal settings. Leave the panel blank to let each user bring their own model (see below).
|
||||
|
||||
### Pulling a local model
|
||||
|
||||
With the **Local** provider selected, the panel manages your Ollama server directly:
|
||||
|
||||
- **Installed on the server** lists the models Ollama already has, with a **Refresh** button. Click a model to select it.
|
||||
- **Pull a recommended model** downloads a model with a live progress bar. The one recommended model is **Qwen3 — 8B** (`qwen3:8b`) — *best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0*. Once the pull finishes it is selected automatically.
|
||||
|
||||
You can also select any other model already installed on the server, or type a model id by hand.
|
||||
|
||||
## Per-user configuration
|
||||
|
||||
If an admin leaves the instance config blank, each user can configure their own model under **Settings → Integrations → AI parsing** (the section only appears when the addon is enabled):
|
||||
|
||||
> *Use your own AI model to extract bookings from uploaded files. This applies only when your administrator has not configured a model for the whole instance.*
|
||||
|
||||
The fields mirror the admin panel — provider, model, base URL (for local / OpenAI), and an API key that is *stored encrypted* (leave blank to keep the current key). There is also a **Send documents as images** toggle for vision-capable models.
|
||||
|
||||
> **Precedence:** an admin instance model always wins. Personal settings only take effect when no instance-wide model is configured.
|
||||
|
||||
## Importing a booking with AI
|
||||
|
||||
The upload flow is the normal booking import — the AI simply runs behind it:
|
||||
|
||||
1. In the trip planner, open the **Reservations** tab and click **Import from file**.
|
||||
2. Drop your files (EML, PDF, PKPass, HTML, TXT — up to 5 files, 10 MB each) onto the upload area.
|
||||
3. The upload dialog closes right away and a **background widget** (bottom-right) shows *Parsing files…* with a running count. You can keep navigating TREK while it works; the widget survives a page reload and even follows you to other pages.
|
||||
4. When parsing finishes, click the widget's **Import** button to start the review.
|
||||
5. Each parsed booking opens **pre-filled in the normal reservation (or transport) editor**, one at a time. Nothing is saved until you confirm each one.
|
||||
|
||||
### What gets filled in and created
|
||||
|
||||
The model is asked to capture the full booking — including **every leg of a multi-segment flight** — and, on save, TREK wires each item into the trip:
|
||||
|
||||
- **Fields** — booking/confirmation code, dates and times, and per type: seat, class, platform, total price and currency; hotels bring their address, rental cars their company, restaurants and events their venue with phone and website.
|
||||
- **Places** — hotel, restaurant and event venues (and un-geocoded transport stops) are geocoded and added as trip places, so the map pin appears.
|
||||
- **Accommodations** — a hotel booking creates the accommodation on the matching check-in/check-out days.
|
||||
- **Linked cost** — if the [Costs/Budget addon](Budget-Tracking) is enabled and the booking has a price, a linked expense is created. Without that addon, the price stays on the reservation only.
|
||||
- **Source document** — the uploaded file is attached to the reservation's files.
|
||||
|
||||
## Good to know
|
||||
|
||||
- **No new environment variables and no manual migration** — the addon is configured entirely in the UI.
|
||||
- **Local inference can be slow.** On a CPU-only host a single booking can take tens of seconds to a couple of minutes; TREK allows local models up to 5 minutes per document. Uploads are parsed **one at a time** per user, so several files queue rather than run in parallel.
|
||||
- **Parse jobs are kept for about 10 minutes** after they finish. Start the review within that window.
|
||||
- **Privacy** — with the Local provider nothing leaves your network. With OpenAI or Anthropic, the document's text (or, for Anthropic, the PDF itself) is sent to that provider for extraction.
|
||||
- **API keys are never returned in plaintext** — they are encrypted at rest and only ever shown masked.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Reservations-and-Bookings](Reservations-and-Bookings) — the booking import flow this extends
|
||||
- [Admin-Addons](Admin-Addons) — enabling the addon
|
||||
- [Budget-Tracking](Budget-Tracking) — linked costs from imported bookings
|
||||
- [Transport: Flights, Trains, Cars](Transport-Flights-Trains-Cars)
|
||||
@@ -23,6 +23,8 @@ The following addons are registered in the system (defined in `server/src/db/see
|
||||
| `collab` | trip | Notes, polls, and live chat for trip collaboration. See [Real-Time-Collaboration](Real-Time-Collaboration). |
|
||||
| `journey` | global | Trip tracking and travel journal — check-ins, photos, and daily stories. See [Journey-Journal](Journey-Journal). |
|
||||
| `collections` | global | A personal, server-wide library of saved places in named lists, with idea/want/visited status, categories, and fusion sharing with per-member roles. See [Collections](Collections). |
|
||||
| `airtrail` | integration | Sync flights from your self-hosted AirTrail instance into trips. |
|
||||
| `llm_parsing` | integration | AI Parsing — an LLM fallback that extracts bookings from confirmation files KDE Itinerary can't read. See [AI-Booking-Import](AI-Booking-Import). |
|
||||
| `naver_list_import` | trip | Import places from shared Naver Maps lists directly into a trip. |
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Open the **Budget** tab inside the trip planner. The tab is only visible when th
|
||||
|
||||
## Currency
|
||||
|
||||
Use the currency picker in the Budget toolbar to select one currency for the entire trip. 46 currencies are supported (EUR, USD, GBP, JPY, CHF, CZK, PLN, SEK, NOK, DKK, TRY, THB, AUD, CAD, NZD, BRL, MXN, INR, IDR, MYR, PHP, SGD, KRW, CNY, HKD, TWD, ZAR, AED, SAR, ILS, EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, BDT, LKR, VND, CLP, COP, PEN, ARS). All amounts are displayed in this currency.
|
||||
Use the currency picker in the Budget toolbar to select one currency for the entire trip. 47 currencies are supported (EUR, USD, GBP, JPY, CHF, CZK, PLN, SEK, NOK, DKK, TRY, THB, AUD, CAD, NZD, BRL, MXN, INR, IDR, MYR, PHP, SGD, KRW, CNY, HKD, TWD, ZAR, AED, SAR, ILS, EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, KGS, BDT, LKR, VND, CLP, COP, PEN, ARS). All amounts are displayed in this currency.
|
||||
|
||||
## Categories
|
||||
|
||||
|
||||
+15
-4
@@ -46,17 +46,28 @@ Places can be assigned a **category** from the same admin-defined set used acros
|
||||
|
||||
## Place detail
|
||||
|
||||
Clicking a saved place opens a detail sheet showing a cover photo (fetched automatically when the place has none of its own), its category, a live status control, a markdown description, and links. From there you can edit the place, **copy it into a trip**, or remove it from the list.
|
||||
Clicking a saved place opens a detail sheet showing a cover photo (fetched automatically when the place has none of its own), its category, its [labels](#custom-labels), a live status control, a markdown description, and links. Editing the place also lets you assign its labels. From there you can edit the place, **copy it into a trip**, or remove it from the list.
|
||||
|
||||
## Filtering and bulk actions
|
||||
|
||||
Above the places sit two compact filters — by **status** and by **category** — plus a **Select** toggle. In select mode you can:
|
||||
Above the places sit compact filters — by **status**, by **category**, and by [**label**](#custom-labels) — plus a **Select** toggle. In select mode you can:
|
||||
|
||||
- **Select all** the currently filtered places.
|
||||
- **Assign label** — add one or more of the list's labels to every selected place at once.
|
||||
- **Copy to trip** — copies the selected places into any of your trips (carrying their name, description, category, notes, price, coordinates, photo and tags).
|
||||
- **Move** or **Duplicate** the selection into another of your lists.
|
||||
- **Delete** the selection.
|
||||
|
||||
## Custom labels
|
||||
|
||||
Each list can define its own **labels** — for example *Berlin*, *Hamburg* and *Ostsee* inside a "Germany 2026" list — to group its saved places beyond the shared category set. Labels belong to the one list they're created in and are shared with everyone on it.
|
||||
|
||||
- **Manage** — a label manager (reachable from the filter bar) lets you create, rename, recolour and delete labels.
|
||||
- **Assign** — set a place's labels from its detail sheet, or add labels to many places at once with **Assign label** in the selection bar.
|
||||
- **Filter** — pick one or more labels in the filter bar to narrow **both the place list and the map** to places carrying any of them.
|
||||
|
||||
Managing and assigning labels needs edit rights (Editor or Admin); **filtering by label is available to every member, including Viewers**. Moving a place to another list drops its labels, since labels belong to the source list.
|
||||
|
||||
## Sharing a list (fusion)
|
||||
|
||||
Lists are private by default. The owner can **share** a list by inviting other users, similar to Vacay fusion. Invited users see the list once they accept, and changes sync live over websocket.
|
||||
@@ -67,8 +78,8 @@ When sharing, the owner assigns each member a permission role, and can change it
|
||||
|
||||
| Role | Can do |
|
||||
|---|---|
|
||||
| **Viewer** | View the list and copy its places into their own trips — no changes to the list. |
|
||||
| **Editor** *(default)* | Add new places and edit existing ones. |
|
||||
| **Viewer** | View the list and copy its places into their own trips, and filter by label — no changes to the list. |
|
||||
| **Editor** *(default)* | Add new places and edit existing ones, and manage + assign the list's labels. |
|
||||
| **Admin** | Everything an editor can, plus delete places. |
|
||||
|
||||
The owner always has full control. The owner can also remove a member, and a member can leave a shared list themselves. Permissions are enforced on the server, so a role can only ever do what it is allowed to.
|
||||
|
||||
@@ -64,7 +64,7 @@ Setting `ENCRYPTION_KEY` explicitly is recommended so you can back it up indepen
|
||||
|
||||
### `DEFAULT_LANGUAGE` — Supported Codes
|
||||
|
||||
You can set `DEFAULT_LANGUAGE` to any of the 20 languages TREK ships. The currently supported codes are:
|
||||
You can set `DEFAULT_LANGUAGE` to any of the 22 languages TREK ships. The currently supported codes are:
|
||||
|
||||
| Code | Language |
|
||||
|---------|--------------------|
|
||||
@@ -88,6 +88,8 @@ You can set `DEFAULT_LANGUAGE` to any of the 20 languages TREK ships. The curren
|
||||
| `ko` | 한국어 |
|
||||
| `uk` | Українська |
|
||||
| `gr` | Ελληνικά |
|
||||
| `sv` | Svenska |
|
||||
| `vi` | Tiếng Việt |
|
||||
|
||||
If you set a code that isn't supported, TREK falls back to English (`en`). This list grows as new
|
||||
translations are added to TREK.
|
||||
@@ -179,10 +181,24 @@ randomly generated password that is printed to the server log. Once any user exi
|
||||
|
||||
For setup instructions, see [MCP-Overview](MCP-Overview).
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------------------------|------------------------------------------|---------|
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
| Variable | Description | Default |
|
||||
|----------------------------|---------------------------------------------------------------------------------------------------------|---------|
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
| `MCP_SESSION_TTL` | Session idle timeout in seconds (max 86400) | `3600` |
|
||||
| `MCP_SSE_KEEPALIVE` | SSE keep-alive ping interval in seconds — keeps the stream alive through reverse proxies. `0` disables the pings; an open stream still refreshes the session's idle timeout. | `25` |
|
||||
|
||||
---
|
||||
|
||||
## API Docs
|
||||
|
||||
| Variable | Description | Default |
|
||||
|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
|
||||
| `TREK_API_DOCS_ENABLED` | Serve interactive OpenAPI/Swagger docs at `/api/docs` (raw spec at `/api/docs-json`). The spec enumerates every route including the admin surface, so it is off by default. | `false` |
|
||||
|
||||
With the flag on, `/api/docs` lists every REST endpoint with try-it-out; authorize with a session JWT
|
||||
via the Bearer button (the API accepts `Authorization: Bearer <jwt>` everywhere as the cookie fallback).
|
||||
Request bodies validated with Zod are documented automatically from the same schemas.
|
||||
|
||||
---
|
||||
|
||||
@@ -198,6 +214,20 @@ running TREK from source, install `libkitinerary-bin` (Debian trixie / Ubuntu 25
|
||||
directly and place it anywhere on `PATH`. The `GET /api/health/features` endpoint returns `{ "bookingImport": true }`
|
||||
when the binary is found, and the Import button in the Reservations panel is hidden when it is not.
|
||||
|
||||
Booking import can also fall back to an AI model for documents KDE Itinerary can't read. That feature (the **AI Parsing** addon) is configured entirely in the UI and needs no environment variables — see [AI-Booking-Import](AI-Booking-Import).
|
||||
|
||||
---
|
||||
|
||||
## Public Transit (Transitous)
|
||||
|
||||
Public-transit routing in the planner is powered by [Transitous](https://transitous.org/), a free community MOTIS service — no API key is required. See [Transport: Flights, Trains, Cars](Transport-Flights-Trains-Cars) for the feature itself.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|
|
||||
| `TRANSIT_API_URL` | Base URL of the transit routing API. TREK's server proxies requests to it. Point this at your own self-hosted [MOTIS](https://github.com/motis-project/motis) instance if you want zero third-party egress. A trailing slash is stripped. | `https://api.transitous.org` |
|
||||
|
||||
When left at the default, using the transit feature makes the TREK **server** send outbound HTTPS requests to `api.transitous.org` (with an identifying User-Agent, as the Transitous usage policy asks). No transit request is made until a user actually searches for a journey.
|
||||
|
||||
---
|
||||
|
||||
## Storage & Paths
|
||||
@@ -235,6 +265,24 @@ seeded.
|
||||
|
||||
---
|
||||
|
||||
## Plugins
|
||||
|
||||
The plugin system is **on by default**. The runtime and the Admin → Plugins panel are available out of the box, but installed plugins still have to be activated one by one — so no third-party code runs until an admin turns a specific plugin on. Set `TREK_PLUGINS_ENABLED=false` to switch the whole system off. See [Plugins](Plugins) for the full system and [Plugin-Permissions](Plugin-Permissions) for the isolation model.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|
|
||||
| `TREK_PLUGINS_ENABLED` | Master switch for the plugin system. Enabled unless set to `false` (also accepts `0`, `off`, `no`, case-insensitive). Turning it off is a kill switch — installed plugins stay on disk but nothing runs. | enabled |
|
||||
| `TREK_PLUGINS_DIR` | Directory where installed plugin **code** is stored. Persist it as a volume if you use plugins. | `<data>/plugins` |
|
||||
| `TREK_PLUGINS_DATA_DIR` | Directory for each plugin's own **data** (its private SQLite file). Kept separate from the code tree; persist it as a volume too. | `<data>/plugins-data` |
|
||||
| `TREK_PLUGIN_REGISTRY_URL` | Override the plugin registry index the *Discover* tab browses. Point it at your own fork or mirror of the registry. | `https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json` |
|
||||
| `TREK_PLUGIN_MAX_RSS_MB` | Per-plugin memory ceiling in MB. A plugin process that exceeds it is stopped. | `300` |
|
||||
| `TREK_PLUGIN_PERMISSIONS` | Set to `off` to opt **out** of the Node.js OS-level permission sandbox for plugin child processes (not recommended). Any other value keeps the sandbox on. | `on` |
|
||||
| `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS`| Set to `on` to let a plugin's declared outbound hosts resolve to private/internal addresses (e.g. a service on your LAN). By default connections to private, loopback, link-local and metadata addresses are refused. | off (private egress blocked) |
|
||||
|
||||
All of these are optional — the defaults are safe. Set `TREK_PLUGINS_ENABLED=false` if you want to switch the plugin system off entirely.
|
||||
|
||||
---
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Reverse-Proxy](Reverse-Proxy) — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
|
||||
|
||||
@@ -39,8 +39,12 @@ See [MCP-Setup](MCP-Setup) for step-by-step instructions for each method.
|
||||
|---|---|---|
|
||||
| Requests per minute per user | 300 | `MCP_RATE_LIMIT` |
|
||||
| Max concurrent sessions per user | 20 | `MCP_MAX_SESSION_PER_USER` |
|
||||
| Session idle timeout (seconds) | 3600 | `MCP_SESSION_TTL` |
|
||||
| SSE keep-alive interval (seconds, 0 = off) | 25 | `MCP_SSE_KEEPALIVE` |
|
||||
|
||||
Rate limits are tracked per user–client pair, so each OAuth client has its own independent window. Sessions expire after 1 hour of inactivity.
|
||||
Rate limits are tracked per user–client pair, so each OAuth client has its own independent window. Sessions expire after 1 hour of inactivity by default (`MCP_SESSION_TTL`); an open SSE stream counts as activity. The server also sends an SSE comment ping every 25 seconds so reverse proxies with idle timeouts (e.g. nginx's default 60s) don't kill the stream between tool calls.
|
||||
|
||||
> **Kubernetes / multi-replica:** MCP sessions are held in memory per instance. With more than one replica you need sticky sessions (or a single replica), or clients will intermittently see `404 Session not found`.
|
||||
|
||||
## Endpoint
|
||||
|
||||
|
||||
@@ -43,8 +43,9 @@ At zoom level 12 or higher, small pill-shaped labels appear between consecutive
|
||||
Flights, trains, cars, and cruises can be drawn as overlays between their endpoint places. Overlays are **off by default** — activate each reservation individually by clicking the small **Route** icon next to the booking row in the day sidebar. The selection is remembered per trip in your browser. Click the icon again to hide it.
|
||||
|
||||
- **Flights and cruises** — geodesic great-circle arcs
|
||||
- **Trains and cars** — straight lines
|
||||
- **Antimeridian crossings** — arcs that would cross the date line are split into sub-arcs to avoid wrapping across the map
|
||||
- **Cars, buses, taxis and bicycles** — real routed lines that follow actual roads, fetched on demand from a public OSRM router (driving for car/bus/taxi, cycling for bicycle). A straight line is shown while the route loads and kept if routing fails or the trip is very long (~2000 km+)
|
||||
- **Trains** — a straight line between the endpoints; a multi-leg train draws its whole station chain (from → stop → to)
|
||||
- **Antimeridian crossings** — routes that cross the date line now draw as one continuous arc instead of splitting into disconnected segments at the map edges
|
||||
- **Endpoint markers** — pill-shaped labels with the transport icon and the endpoint code (e.g. IATA airport code) or location name
|
||||
- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights and require Settings → Display → **Route calculation** to be ON.
|
||||
- **Confirmed reservations** — solid line; **Pending** — dashed line
|
||||
|
||||
@@ -50,6 +50,44 @@ Each item row contains:
|
||||
|
||||
Hovering over an item reveals a **category picker** (colored dot), a **rename** button (pencil icon), and a **delete** button. Add new items using the inline "add item" row at the bottom of each category.
|
||||
|
||||
## Sharing packing items
|
||||
|
||||
Every packing item has a sharing tier that controls who sees it and who is bringing it. By default everything sits in the shared group pool, exactly as before — the tiers are opt-in per item.
|
||||
|
||||
### The two views
|
||||
|
||||
Two pills at the top of the list switch what you're looking at:
|
||||
|
||||
- **Shared** — the group pool: items everyone on the trip can see.
|
||||
- **My list** — your own items: your personal items, things you've been asked to bring, and things you shared with specific people.
|
||||
|
||||
Each pill shows a count of the items in it.
|
||||
|
||||
### The three tiers
|
||||
|
||||
Open an item's **Sharing** control (the share icon on the row) to move it between tiers:
|
||||
|
||||
- **Shared** — *In the group pool, visible to everyone.* This is where every item starts.
|
||||
- **Personal** — *Private — only you can see it.*
|
||||
- **Shared with…** — pick specific trip members below the two tier options. The item then shows only on your list and on theirs. (If you're the only one on the trip, this reads *No one else on this trip yet*.)
|
||||
|
||||
New items inherit the view you add them in: adding an item while in **My list** makes it Personal, adding it in **Shared** makes it Common. To share an item with specific people, add it first, then open its Sharing control and choose them.
|
||||
|
||||
Only the item's owner (the person bringing it) can change its sharing. Someone you shared an item *with* just sees it on their **My list** with a **by {name}** badge and can tick it off — they don't manage who else it's shared with.
|
||||
|
||||
### Who's bringing what
|
||||
|
||||
Every item in the **Shared** pool shows who is bringing it. For an item someone else added, other members see two quick actions instead of the Sharing control:
|
||||
|
||||
- **I can bring that too** — pledge to co-bring it. The item's badge then shows a **+1** next to the original bringer. Tap again (*I'm not bringing it*) to withdraw.
|
||||
- **Copy to my list** — clone the item onto your own personal list as a separate private copy, leaving the shared one untouched.
|
||||
|
||||
> Items created before this feature have no assigned bringer, so they show no "brought by" badge until someone edits their sharing.
|
||||
|
||||
> **Note:** this per-item sharing is separate from assigning **members to a category** (above). Category assignments only send a packing notification — they don't change who can see an item.
|
||||
|
||||
All of this is still gated by the `packing_edit` permission; there is no extra addon or admin toggle.
|
||||
|
||||
## Bag tracking
|
||||
|
||||
Bag tracking is only available when an admin has enabled it.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user