Compare commits

..

2 Commits

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

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

Closes #1219
2026-06-17 10:44:05 +02:00
862 changed files with 13290 additions and 23107 deletions
-1
View File
@@ -32,7 +32,6 @@ server/tests/
server/vitest.config.ts
server/reset-admin.js
**/*.test.ts
**/*.spec.ts
wiki/
scripts/
charts/
+15 -9
View File
@@ -46,11 +46,23 @@ COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# better-sqlite3 native addon requires build tools (purged after compile).
# kitinerary-extractor for booking-confirmation import:
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
# arm64 — apt package (KDE publishes no arm64 static binary)
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
libkitinerary-bin && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
npm ci --workspace=server --omit=dev && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
rm /tmp/ki.tgz; \
else \
apt-get install -y --no-install-recommends libkitinerary-bin && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
fi && \
apt-get purge -y python3 build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
@@ -73,12 +85,6 @@ COPY --from=server-builder /app/server/dist ./server/dist
COPY --from=server-builder /app/server/assets ./server/assets
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
# raw .ts source — it never enters dist, so it must be copied in explicitly or
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
COPY server/reset-admin.js ./server/reset-admin.js
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0"?>
<CommunityApplications>
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<DonateText>Support TREK development</DonateText>
</CommunityApplications>
+1 -1
View File
@@ -39,7 +39,7 @@ See `values.yaml` for more options.
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
- PVCs require a default StorageClass or specify one as needed.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.1.3
version: 3.1.0
description: Minimal Helm chart for TREK app
appVersion: "3.1.3"
appVersion: "3.1.0"
-6
View File
@@ -70,9 +70,3 @@ data:
{{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_URL }}
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
{{- end }}
-14
View File
@@ -5,16 +5,9 @@ metadata:
name: {{ include "trek.fullname" . }}-data
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.data.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.data.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.data.size }}
@@ -25,16 +18,9 @@ metadata:
name: {{ include "trek.fullname" . }}-uploads
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.uploads.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.uploads.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
-11
View File
@@ -67,12 +67,6 @@ env:
# Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 20.
# OVERPASS_URL: ""
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
# OVERPASS_TIMEOUT_MS: "12000"
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
# Secret environment variables stored in a Kubernetes Secret.
@@ -104,13 +98,8 @@ persistence:
enabled: true
data:
size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: ""
annotations: {}
uploads:
size: 1Gi
storageClassName: ""
annotations: {}
resources:
requests:
+1 -1
View File
@@ -13,7 +13,7 @@
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "3.1.3",
"version": "3.1.0",
"private": true,
"type": "module",
"scripts": {
@@ -34,7 +34,6 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -82,7 +81,7 @@
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "8.1.0",
"vite": "^8.0.16",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.9"
}
-2
View File
@@ -20,7 +20,6 @@ import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast'
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
@@ -209,7 +208,6 @@ export default function App() {
<TranslationProvider>
{!isAuthPage && <SystemNoticeHost />}
<ToastContainer />
{!isAuthPage && <BackgroundTasksWidget />}
<OfflineBanner />
<Routes>
<Route path="/" element={<RootRedirect />} />
+4 -56
View File
@@ -41,7 +41,6 @@ import {
type BookingImportPreviewItem,
type BookingImportPreviewResponse,
type BookingImportConfirmResponse,
type BookingImportMode,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { isReachable, probeNow } from '../sync/connectivity'
@@ -101,7 +100,6 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.',
sv: 'För många försök. Prova igen senare.',
}
function translateRateLimit(): string {
@@ -443,41 +441,6 @@ export const adminApi = {
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
// Local LLM (Ollama) management for the AI-parsing addon.
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
/** Pull a model, streaming Ollama's NDJSON progress to `onProgress`. */
llmLocalPull: async (
baseUrl: string,
model: string,
onProgress: (p: { status?: string; total?: number; completed?: number; error?: string }) => void,
): Promise<void> => {
const res = await fetch('/api/admin/llm/local/pull', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseUrl, model }),
})
if (!res.ok || !res.body) {
let msg = `Pull failed (${res.status})`
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
throw new Error(msg)
}
const reader = res.body.getReader()
const dec = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
}
}
},
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
@@ -526,7 +489,7 @@ export const addonsApi = {
export const airtrailApi = {
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
@@ -632,7 +595,6 @@ export const budgetApi = {
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
@@ -661,31 +623,17 @@ export const reservationsApi = {
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
fd.append('mode', mode)
// No client-side timeout: kitinerary + LLM extraction routinely exceeds the
// global 8s default (a cold local model alone can take ~45s).
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
// Start a background parse: returns a job id at once; progress + result arrive
// over the WebSocket (import:progress / import:done / import:error).
importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
fd.append('mode', mode)
return apiClient.post(`/trips/${tripId}/reservations/import/booking/async`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
},
// Poll a background job — recovery path when a WebSocket push was missed.
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data),
}
export const healthApi = {
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data),
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
}
export const weatherApi = {
+2 -227
View File
@@ -4,8 +4,7 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
@@ -299,12 +298,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span>
</div>
{integrationAddons.map(addon => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'llm_parsing' && addon.enabled && (
<LlmParsingConfig addon={addon} />
)}
</div>
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
))}
</div>
)}
@@ -315,225 +309,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
}
const MASKED = '••••••••'
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
]
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
* overrides per-user config (see server llmConfig.ts). The API key is masked on
* read; an unchanged mask is treated as a no-op by the server. For the local
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const toast = useToast()
const cfg = (addon.config ?? {}) as Record<string, unknown>
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
const [saving, setSaving] = useState(false)
// Local-provider model management.
const [installed, setInstalled] = useState<string[]>([])
const [modelsErr, setModelsErr] = useState('')
const [loadingModels, setLoadingModels] = useState(false)
const [pulling, setPulling] = useState<string | null>(null)
const [pullPct, setPullPct] = useState(0)
const [pullStatus, setPullStatus] = useState('')
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
const loadModels = async () => {
if (provider !== 'local') return
setLoadingModels(true)
setModelsErr('')
try {
const res = await adminApi.llmLocalModels(effectiveUrl)
setInstalled(res.models.map(m => m.name))
} catch (e: unknown) {
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
setInstalled([])
} finally {
setLoadingModels(false)
}
}
// Load installed models when the local provider is active.
useEffect(() => {
if (provider === 'local') loadModels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider])
const pull = async (id: string) => {
if (pulling) return
setPulling(id)
setPullPct(0)
setPullStatus('starting…')
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error)
if (p.status) setPullStatus(p.status)
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
})
toast.success('Model pulled')
setModel(id)
await loadModels()
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed')
} finally {
setPulling(null)
setPullPct(0)
setPullStatus('')
}
}
const save = async () => {
setSaving(true)
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
toast.success('Saved')
} catch {
toast.error('Failed to save')
} finally {
setSaving(false)
}
}
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
return (
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
<div className="max-w-2xl space-y-6">
<p className="text-xs text-content-faint">
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
</p>
{/* Connection */}
<section className="space-y-3">
<div className={sectionCls}>Connection</div>
<div>
<span className={labelCls}>Provider</span>
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</div>
{provider !== 'anthropic' && (
<label className="block">
<span className={labelCls}>Base URL</span>
<input className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</label>
)}
<label className="block">
<span className={labelCls}>API key</span>
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</label>
{provider === 'anthropic' && (
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text scanned PDFs need Anthropic.</p>
)}
</section>
{/* Model */}
<section className="space-y-3">
<div className={sectionCls}>Model</div>
<label className="block">
<input className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<p className="text-xs text-content-faint">No models installed yet pull one below.</p>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button
key={name}
title={name}
onClick={() => setModel(name)}
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
>
{name}
</button>
))}
</div>
)}
<div className="border-t border-edge-secondary pt-3">
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
<div className="space-y-1">
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-content">{m.label}</span>
{m.recommended && (
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
)}
</div>
<div className="text-xs text-content-faint">{m.note}</div>
{isPulling && (
<div className="mt-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
{installedHere ? (
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
)
})}
</div>
</div>
</div>
)}
</section>
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)
}
interface AddonRowProps {
addon: Addon
onToggle: (addon: Addon) => void
@@ -6,17 +6,7 @@ import { useToast } from '../shared/Toast'
import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, Place } from '../../types'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
getStylePresets,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleSettingKey,
type GlMapProvider,
} from '../Map/glProviders'
import type { Place } from '../../types'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -28,31 +18,25 @@ const MAP_PRESETS = [
type Defaults = {
temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean
time_format?: string
default_currency?: string
blur_booking_codes?: boolean
map_tile_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
}
type MapProvider = 'leaflet' | GlMapProvider
function normalizeProvider(value: unknown): MapProvider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
}
function styleForProvider(provider: MapProvider, style?: string | null): string {
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style)
}
const MAPBOX_STYLE_PRESETS = [
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
]
function OptionRow({
label,
@@ -112,11 +96,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data)
setMapTileUrl(data.map_tile_url || '')
setMapboxToken(data.mapbox_access_token || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setMapboxStyle(data.mapbox_style || '')
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
@@ -137,10 +120,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
}
if (key === 'mapbox_style') setMapboxStyle('')
toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'))
@@ -190,20 +170,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}
const darkMode = defaults.dark_mode
const mapProvider = normalizeProvider(defaults.map_provider)
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
const saveMapProvider = (nextProvider: MapProvider) => {
const patch: Partial<Defaults> = { map_provider: nextProvider }
if (nextProvider !== 'leaflet') {
// Load + save the new provider's own style slot so the other provider's style is kept.
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
const nextStyle = styleForProvider(nextProvider, slot)
setMapboxStyle(nextStyle)
patch[styleSettingKey(nextProvider)] = nextStyle
}
save(patch)
}
return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
@@ -244,22 +210,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))}
</OptionRow>
{/* Distance */}
<OptionRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
{([
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={defaults.distance_unit === opt.value}
onClick={() => save({ distance_unit: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Time Format */}
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
{([
@@ -276,23 +226,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))}
</OptionRow>
{/* Default Currency */}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.currency')} <ResetButton field="default_currency" />
</label>
<CustomSelect
value={defaults.default_currency || ''}
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
placeholder={t('settings.currency')}
searchable
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
size="sm"
style={{ maxWidth: 240 }}
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
</div>
{/* Blur Booking Codes */}
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
{([
@@ -364,21 +297,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{([
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={mapProvider === opt.value}
onClick={() => saveMapProvider(opt.value)}
active={(defaults.map_provider || 'leaflet') === opt.value}
onClick={() => save({ map_provider: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{mapProvider !== 'leaflet' && (
{defaults.map_provider === 'mapbox-gl' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
{mapProvider === 'mapbox-gl' && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')}
@@ -396,18 +327,17 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxStyle')}
<ResetButton field={styleKey} />
<ResetButton field="mapbox_style" />
</label>
<CustomSelect
value={mapboxStyle}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
@@ -415,18 +345,12 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
type="text"
value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => {
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
setMapboxStyle(nextStyle)
save({ [styleKey]: nextStyle })
}}
placeholder={defaultStyleForProvider(mapProvider)}
onBlur={() => save({ mapbox_style: mapboxStyle })}
placeholder="mapbox://styles/mapbox/standard"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
{mapProvider === 'mapbox-gl' && (
<>
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
@@ -448,8 +372,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</OptionButton>
))}
</OptionRow>
</>
)}
</div>
)}
</div>
@@ -1,163 +0,0 @@
import ReactDOM from 'react-dom'
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi } from '../../api/client'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
/**
* Global, route-independent widget (bottom-right) that tracks background booking
* imports. Mounted once at the app root so it survives navigation. It listens to the
* user's WebSocket for import:progress / import:done / import:error and reflects each
* job; a finished job offers a "review" action that takes the user to the trip, where
* the per-item review flow opens. Polls running jobs as a backstop for missed pushes.
*/
export default function BackgroundTasksWidget() {
const { t } = useTranslation()
const navigate = useNavigate()
const tasks = useBackgroundTasksStore((s) => s.tasks)
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
const setDone = useBackgroundTasksStore((s) => s.setDone)
const setError = useBackgroundTasksStore((s) => s.setError)
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
// On (re)load, reconcile tasks restored from localStorage with the server: a parse
// that was still running when the page reloaded must keep its widget, so re-fetch each
// job's real status (and its parsed items) once. A job the server has since dropped
// (404, expired) is removed so no stale card lingers.
const didRehydrate = useRef(false)
useEffect(() => {
if (didRehydrate.current) return
didRehydrate.current = true
const restored = useBackgroundTasksStore.getState().tasks
for (const task of restored) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch((err: { response?: { status?: number } }) => {
if (err?.response?.status === 404) dismiss(task.id)
})
}
// run once on mount against whatever was rehydrated from storage
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Server pushes import:* to the user on whatever page they're on.
useEffect(() => {
const handler = (e: Record<string, unknown>) => {
const type = typeof e.type === 'string' ? e.type : ''
if (!type.startsWith('import:')) return
const id = String(e.jobId ?? '')
const tripId = String(e.tripId ?? '')
if (!id) return
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
else if (type === 'import:done') {
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
}
addListener(handler)
return () => removeListener(handler)
}, [setProgress, setDone, setError])
// Backstop: poll jobs whose state we still need — running ones (in case a WebSocket push
// was missed) and a restored 'done' task whose items haven't been re-fetched yet (so a
// failed one-shot rehydrate self-heals instead of getting stuck on "preview empty").
useEffect(() => {
const pending = tasks.filter((task) => task.status === 'running' || (task.status === 'done' && task.items === undefined))
if (pending.length === 0) return
const iv = setInterval(() => {
for (const task of pending) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch(() => {})
}
}, 5000)
return () => clearInterval(iv)
}, [tasks, setProgress, setDone, setError])
if (tasks.length === 0) return null
const review = (task: BackgroundImportTask) => {
requestReview(task.id)
navigate(`/trips/${task.tripId}`)
}
return ReactDOM.createPortal(
<div
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
>
{tasks.map((task) => (
<div
key={task.id}
className="bg-surface-card"
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
<div style={{ flexShrink: 0, marginTop: 1 }}>
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.label}
</div>
{task.status === 'running' && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.parsing')}
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
</div>
)}
{task.status === 'done' && (
task.items === undefined ? (
// Restored from a reload; items are being re-fetched (see the poll backstop).
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
) : task.items.length > 0 ? (
<button
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.import')}
</button>
) : (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.previewEmpty')}</div>
)
)}
{task.status === 'error' && (
<div style={{ fontSize: 11, color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
)}
</div>
{task.status !== 'running' && (
<button
onClick={() => dismiss(task.id)}
className="bg-transparent text-content-faint"
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
aria-label={t('common.close')}
>
<X size={13} />
</button>
)}
</div>
))}
</div>,
document.body
)
}
@@ -1,197 +0,0 @@
// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
import { render, screen, waitFor } from '../../../tests/helpers/render'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useAuthStore } from '../../store/authStore'
import { useTripStore } from '../../store/tripStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
import CostsPanel from './CostsPanel'
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: null },
{ id: 2, username: 'bob', avatar_url: null },
]
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) })
})
describe('CostsPanel — settlements in the ledger', () => {
it('renders a settle-up payment as a ledger row with an undo action', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [],
flows: [],
settlements: [
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
],
})
),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
// The expense and the settlement (payment) both appear in the unified ledger.
await screen.findByText('Dinner')
await screen.findByText('Payment')
// The payment row exposes an inline undo (no need to open a separate History modal).
expect(screen.getByTitle('Undo')).toBeInTheDocument()
})
it('records a manual payment via the Add payment button', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 1, ...posted } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '25')
// The footer submit is the second "Add payment" control once the modal is open.
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
const submit = addButtons[addButtons.length - 1]
await user.click(submit)
await waitFor(() => expect(posted).toMatchObject({ amount: 25 }))
})
it('hides payment rows while a text search is active', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [],
flows: [],
settlements: [
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
],
})
),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Payment')
await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner')
// Payment rows have no name, so a search hides them while the matching expense stays.
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
expect(screen.getByText('Dinner')).toBeInTheDocument()
})
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 5 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
await waitFor(() => expect(nums()[1].value).toBe('50'))
expect(nums()[2].value).toBe('50')
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
await user.clear(nums()[1]); await user.type(nums()[1], '30')
await waitFor(() => expect(nums()[2].value).toBe('70'))
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(100)
expect(posted!.payers).toEqual(expect.arrayContaining([
expect.objectContaining({ user_id: 1, amount: 30 }),
expect.objectContaining({ user_id: 2, amount: 70 }),
]))
})
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(39.99)
})
it('marks an expense with no payer as Unfinished', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Hotel')
expect(screen.getByText('Unfinished')).toBeInTheDocument()
})
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
// The participant toggles are buttons; the same names also appear as plain text in
// the Balances sidebar, so target the buttons specifically.
await user.click(screen.getByRole('button', { name: /alice/i }))
await user.click(screen.getByRole('button', { name: /bob/i }))
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
const submit = addBtns[addBtns.length - 1] // footer submit
expect(submit).not.toBeDisabled()
await user.click(submit)
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(120)
expect(posted!.member_ids).toEqual([])
expect(posted!.payers).toEqual([])
})
})
+98 -278
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -39,12 +39,6 @@ interface SettlementData {
settlements: Settlement[]
}
// One row in the unified Costs ledger — either an expense or a settle-up payment,
// carrying the date used to group it by day.
type LedgerEntry =
| { kind: 'expense'; date: string; e: BudgetItem }
| { kind: 'payment'; date: string; s: Settlement }
const round2 = (n: number) => Math.round(n * 100) / 100
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
@@ -68,10 +62,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const [settlement, setSettlement] = useState<SettlementData | null>(null)
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
const [search, setSearch] = useState('')
const [histOpen, setHistOpen] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<BudgetItem | null>(null)
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
const [addingPayment, setAddingPayment] = useState(false)
const people = tripMembers
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
@@ -129,37 +122,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return list
}, [budgetItems, filter, search, me])
// Settlements ("payments") shown inline in the ledger. They have no name, so a
// text search hides them; they're excluded from the "owed" expense filter and,
// under "mine", only show transfers I'm part of.
const filteredSettlements = useMemo(() => {
if (search.trim()) return []
if (filter === 'owed') return []
let list = settlement?.settlements || []
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
return list
}, [settlement, filter, search, me])
const dayGroups = useMemo(() => {
const entries: LedgerEntry[] = [
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
]
const labelOf = (date: string) => {
if (!date) return t('costs.noDate')
try { return new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return date }
const groups: { day: string; items: BudgetItem[] }[] = []
const labelOf = (e: BudgetItem) => {
if (!e.expense_date) return t('costs.noDate')
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
}
// Newest day first; within a day, expenses before payments (insertion order).
const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''))
const groups: { day: string; entries: LedgerEntry[] }[] = []
for (const en of sorted) {
const day = labelOf(en.date)
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
for (const e of sorted) {
const day = labelOf(e)
let g = groups.find(x => x.day === day)
if (!g) { g = { day, entries: [] }; groups.push(g) }
g.entries.push(en)
if (!g) { g = { day, items: [] }; groups.push(g) }
g.items.push(e)
}
return groups
}, [filtered, filteredSettlements, locale, t])
}, [filtered, locale, t])
// ── settle actions ──────────────────────────────────────────────────────
const settleFlow = async (fromId: number, toId: number, amount: number) => {
@@ -303,16 +280,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{search ? t('costs.noMatch') : t('costs.emptyText')}
</div>
) : dayGroups.map(g => {
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
return (
<div key={g.day} style={{ marginBottom: 22 }}>
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
</div>
</div>
)
@@ -325,13 +300,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
{canEdit && (
<button onClick={() => setAddingPayment(true)}
className="text-content-muted bg-surface-secondary border border-edge"
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={13} /> {t('costs.addPayment')}
</button>
)}
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
</button>
</div>
<SettleFlows />
</div>
@@ -357,11 +330,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
)}
{(editingSettlement || addingPayment) && (
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
)}
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
</Modal>
<style>{`
.costs-root {
@@ -467,9 +438,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
{canEdit && (
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
)}
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
</div>
<SettleFlows />
</div>
@@ -489,13 +458,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{dayGroups.length === 0
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
: dayGroups.map(g => {
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
return (
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
</div>
)
})}
@@ -523,27 +490,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const cur = curOf(e)
const payers = (e.payers || []).filter(p => p.amount > 0)
const net = round2(myPaidOf(e) - myShareOf(e))
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in.
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
<Icon size={21} />
{isMobile && isUnfinished && (
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
)}
</span>
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
{isUnfinished && !isMobile && (
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
{t('costs.unfinished')}
</span>
)}
</div>
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
{payers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
{payers.map(p => (
@@ -563,7 +514,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
</div>
@@ -580,32 +531,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)
}
// A settle-up payment as a ledger row — visually distinct from an expense, with
// inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal.
function SettlementRow({ s }: { s: Settlement }) {
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)}${personName(s.to_user_id)}`}>
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
<span className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} {personName(s.to_user_id)}</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div className="text-content" style={{ fontSize: 18, fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
{canEdit && (
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><RotateCcw size={13} /></button>
</div>
)}
</div>
</div>
)
}
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
@@ -637,16 +562,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
function CategoryBreakdown() {
const tot: Record<string, number> = {}
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
let grand = 0
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
// Bars are scaled relative to the most expensive category (the top row fills the
// bar), not to the trip grand total — makes the relative ranking readable.
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map(c => {
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
return (
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
@@ -710,75 +633,37 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
)
}
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
// ledger row and from a manual "Add payment" button, so recording "I sent money to
// X" works the same whether or not there's an outstanding expense behind it.
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
}) {
const { t } = useTranslation()
const toast = useToast()
const otherDefault = people.find(p => p.id !== me)?.id ?? me
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
const [saving, setSaving] = useState(false)
const amt = parseFloat(amount) || 0
const valid = amt > 0 && fromId !== toId
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
const save = async () => {
if (!valid) return
setSaving(true)
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data)
onSaved()
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
}
const inputCls = 'w-full bg-surface-input border border-edge text-content'
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
const total = settlements.reduce((a, s) => a + s.amount, 0)
return (
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label className={labelCls}>{t('costs.from')}</label>
<CustomSelect value={fromId} onChange={v => setFromId(String(v))} options={opts} style={{ width: '100%' }} />
</div>
<div>
<label className={labelCls}>{t('costs.to')}</label>
<CustomSelect value={toId} onChange={v => setToId(String(v))} options={opts} style={{ width: '100%' }} />
</div>
<div>
<label className={labelCls}>{t('costs.amount')}</label>
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
</div>
</Modal>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{settlements.map(s => (
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)}${name(s.to_user_id)}`}>
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
</div>
</div>
))}
</div>
</div>
)
}
// ── Add / edit expense modal ───────────────────────────────────────────────
export interface ExpensePrefill {
name?: string
category?: string
amount?: number
reservationId?: number
}
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
}) {
const { t, locale } = useTranslation()
const toast = useToast()
@@ -786,99 +671,34 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const { convert } = useExchangeRates(base)
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
const [name, setName] = useState(editing?.name || prefill?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [name, setName] = useState(editing?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
// One participant list: a person is "in" the split and may have paid an amount.
// Entering the total auto-distributes it equally across the non-pinned participants;
// touching an amount pins it and the rest rebalance so the paid amounts always sum
// back to the total. Leaving every amount blank = an unfinished expense (counts
// toward the trip total only, never settlements, until who-paid is filled in).
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(editing.total_price) : ''
if (prefill?.amount != null) return String(prefill.amount)
return ''
})
const [participants, setParticipants] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [paid, setPaid] = useState<Record<number, string>>(() => {
const [payers, setPayers] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
return m
})
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
// payer amounts load as pinned so opening an expense never reshuffles them.
const [dirty, setDirty] = useState<Set<number>>(() =>
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
const [split, setSplit] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [saving, setSaving] = useState(false)
const totalNum = parseFloat(total) || 0
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
const paidEntered = paidSum > 0
const balanced = Math.abs(paidSum - totalNum) < 0.01
const each = participants.size > 0 ? totalNum / participants.size : 0
// No participants = a recorded total with nobody to split with (e.g. a booking
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
// people only adds the who-owes-whom split on top.
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
const splitCents = (amount: number, n: number): number[] => {
if (n <= 0) return []
const cents = Math.max(0, Math.round(amount * 100))
const base = Math.floor(cents / n), rem = cents - base * n
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
}
// Recompute the non-pinned participants so every paid amount sums to the total.
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
const ids = [...parts]
const free = ids.filter(id => !dirtySet.has(id))
if (free.length === 0) return paidMap
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
const shares = splitCents(totalVal - pinnedSum, free.length)
const next = { ...paidMap }
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
return next
}
const onTotalChange = (v: string) => {
v = v.replace(',', '.')
setTotal(v)
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
}
const onPaidChange = (id: number, v: string) => {
v = v.replace(',', '.')
const nextDirty = new Set(dirty); nextDirty.add(id)
setDirty(nextDirty)
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
}
const toggleParticipant = (id: number) => {
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
else nextParts.add(id)
setParticipants(nextParts); setDirty(nextDirty)
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
}
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
const each = split.size > 0 ? payersTotal / split.size : 0
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
const save = async () => {
if (!valid) return
setSaving(true)
const payerList = [...participants]
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
.filter(p => p.amount > 0)
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
const data = {
name: name.trim(), category: cat,
// Store the actual currency the amounts were entered in; conversion to the
// viewer's display currency happens live (real rates), no manual rate.
currency,
payers: payerList, member_ids: [...participants],
payers: payerList, member_ids: [...split],
expense_date: day || null,
// Always record the entered total: the server keeps it as-is for an unfinished
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
total_price: totalNum,
// Link a freshly-created expense to its booking (create-from-booking flow).
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
@@ -908,9 +728,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
onChange={e => onTotalChange(e.target.value)}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
@@ -926,11 +744,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
</div>
{currency !== base && totalNum > 0 && (
{currency !== base && payersTotal > 0 && (
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{formatMoney(totalNum, currency, locale)}</span>
<span>{formatMoney(payersTotal, currency, locale)}</span>
<span className="text-content-faint"></span>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
<span className="text-content-faint">· {t('costs.liveRate')}</span>
</div>
)}
@@ -955,37 +773,39 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div>
<label className={labelCls}>{t('costs.whoPaid')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map((p, idx) => {
const on = participants.has(p.id)
return (
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
<button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
</button>
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
onChange={e => onPaidChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
) : (
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
)}
{people.map(p => (
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
onChange={e => setPayers(prev => ({ ...prev, [p.id]: e.target.value }))}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
</div>
))}
</div>
</div>
<div>
<label className={labelCls}>{t('costs.splitBetween')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
{people.map(p => {
const on = split.has(p.id)
return (
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
{p.id === me ? t('costs.you') : p.username}
</button>
)
})}
</div>
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<span className="text-content-faint">
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
{paidEntered
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
</div>
</div>
</div>
@@ -32,32 +32,8 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
/**
* Legacy / English free-text categories (and reservation type labels) mapped to
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
* which never matched the lowercase keys and fell through to `other`.
*/
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
transport: 'transport', transportation: 'transport',
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
grocery: 'groceries', groceries: 'groceries',
activity: 'activities', activities: 'activities',
sightseeing: 'sightseeing', sights: 'sightseeing',
shop: 'shopping', shopping: 'shopping',
fee: 'fees', fees: 'fees',
health: 'health', medical: 'health',
tip: 'tips', tips: 'tips',
other: 'other', misc: 'other',
}
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
/** Map any stored category (incl. legacy free-text values) to a known meta. */
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
if (!cat) return COST_CAT_META.other
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
return COST_CAT_META.other
}
@@ -1,11 +1,7 @@
import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react'
import { forwardRef, useImperativeHandle, useRef } from 'react'
import { useSettingsStore } from '../../store/settingsStore'
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
import type { JourneyMapGLHandle } from './JourneyMapGL'
// Lazy-load the GL renderer (and its ~230 KB gzip engine) so Leaflet-only
// installs never download it — it ships only once a GL provider is picked.
const JourneyMapGL = lazy(() => import('./JourneyMapGL'))
import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
// Unified handle — both providers expose the same three methods.
export type JourneyMapAutoHandle = JourneyMapHandle
@@ -41,9 +37,8 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
const glRef = useRef<JourneyMapGLHandle>(null)
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
// supplied a token yet. MapLibre/OpenFreeMap is tokenless.
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
// supplied a token yet — otherwise the map would just show a stub.
const useGL = provider === 'mapbox-gl' && !!token
useImperativeHandle(ref, () => ({
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
@@ -52,12 +47,8 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
}), [useGL])
if (useGL) {
return (
<Suspense fallback={null}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<JourneyMapGL ref={glRef} {...(props as any)} glProvider={glProvider} />
</Suspense>
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <JourneyMapGL ref={glRef} {...(props as any)} />
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <JourneyMap ref={leafletRef} {...(props as any)} />
+35 -63
View File
@@ -1,11 +1,8 @@
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void
@@ -35,7 +32,6 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
paddingBottom?: number
glProvider?: GlMapProvider
}
interface Item {
@@ -99,10 +95,8 @@ function ensureJourneyPopupStyle() {
const s = document.createElement('style')
s.id = 'trek-journey-popup-style'
s.textContent = `
.mapboxgl-popup.trek-journey-popup,
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content {
.mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
padding: 9px 14px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
@@ -114,24 +108,20 @@ function ensureJourneyPopupStyle() {
min-width: 160px;
max-width: 280px;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content {
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
background: rgba(24, 24, 27, 0.88);
border-color: rgba(255, 255, 255, 0.08);
color: #FAFAFA;
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-tip {
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
border-top-color: rgba(255, 255, 255, 0.94);
border-bottom-color: rgba(255, 255, 255, 0.94);
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-tip {
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
border-top-color: rgba(24, 24, 27, 0.88);
border-bottom-color: rgba(24, 24, 27, 0.88);
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
.trek-journey-popup-title {
font-size: 13.5px;
font-weight: 600;
@@ -142,8 +132,7 @@ function ensureJourneyPopupStyle() {
overflow: hidden;
text-overflow: ellipsis;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.trek-journey-popup-sub {
display: flex;
align-items: baseline;
@@ -154,8 +143,7 @@ function ensureJourneyPopupStyle() {
line-height: 1.35;
white-space: nowrap;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.trek-journey-popup-place {
min-width: 0;
overflow: hidden;
@@ -206,29 +194,20 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl' },
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
ref
) {
const stableTrail = trail || EMPTY_TRAIL
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const mapLang = useSettingsStore(s => s.settings.language)
const isMapLibre = glProvider === 'maplibre-gl'
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const enableMapbox3d = !isMapLibre && mapbox3d
const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<string, any>>(new Map())
const mapRef = useRef<mapboxgl.Map | null>(null)
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
const itemsRef = useRef<Item[]>([])
const highlightedRef = useRef<string | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const popupRef = useRef<any | null>(null)
const popupRef = useRef<mapboxgl.Popup | null>(null)
const onMarkerClickRef = useRef(onMarkerClick)
onMarkerClickRef.current = onMarkerClick
const darkRef = useRef(dark)
@@ -268,7 +247,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
const el = popupRef.current.getElement()
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
} else {
popupRef.current = new gl.Popup({
popupRef.current = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
closeOnMove: false,
@@ -281,7 +260,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
.setHTML(html)
.addTo(mapRef.current)
}
}, [gl])
}, [])
const hidePopup = useCallback(() => {
if (popupRef.current) {
@@ -326,11 +305,11 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({
center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 14),
pitch: enableMapbox3d ? 45 : 0,
pitch: mapbox3d ? 45 : 0,
duration: 600,
})
} catch { /* map not yet ready */ }
}, [highlightMarker, enableMapbox3d])
}, [highlightMarker, mapbox3d])
const invalidateSize = useCallback(() => {
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
@@ -341,46 +320,39 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// Build map once per style/token change. Markers and layers are rebuilt
// inside the same effect so they stay in sync with the active style.
useEffect(() => {
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
if (!containerRef.current || !mapboxToken) return
mapboxgl.accessToken = mapboxToken
const items = buildItems(entries)
itemsRef.current = items
const bounds = new gl.LngLatBounds()
const bounds = new mapboxgl.LngLatBounds()
items.forEach(i => bounds.extend([i.lng, i.lat]))
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
const hasPoints = items.length > 0 || stableTrail.length > 0
const mapOptions: Record<string, unknown> = {
const map = new mapboxgl.Map({
container: containerRef.current,
style: glStyle,
style: mapboxStyle,
center: hasPoints ? bounds.getCenter() : [0, 30],
zoom: hasPoints ? 2 : 1,
pitch: enableMapbox3d && fullScreen ? 45 : 0,
pitch: mapbox3d && fullScreen ? 45 : 0,
attributionControl: true,
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
const map = new gl.Map(mapOptions as any)
projection: mapboxQuality ? 'globe' : 'mercator',
})
mapRef.current = map
map.on('load', () => {
if (enableMapbox3d) {
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
if (supportsCustom3d(glStyle)) addCustom3dBuildings(map, !!darkRef.current)
if (mapbox3d) {
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
}
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
// stay pinned to their coordinates at every zoom and pitch.
if (glStyle === MAPBOX_DEFAULT_STYLE) {
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
try { map.setTerrain(null) } catch { /* noop */ }
}
// Pin the basemap label language to the UI language so labels don't fall back to the
// browser/OS locale and stack multiple scripts per place (#1299).
if (!isMapLibre && isStandardFamily(glStyle)) {
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support it */ }
}
// route trail — dashed line connecting entries in time order
if (items.length > 1) {
@@ -411,7 +383,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// markers
items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false)
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat])
.addTo(map)
el.addEventListener('click', (ev) => {
@@ -428,7 +400,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
map.fitBounds(bounds, {
padding: { top: 50, bottom: pb, left: 50, right: 50 },
maxZoom: 16,
pitch: enableMapbox3d && fullScreen ? 45 : 0,
pitch: mapbox3d && fullScreen ? 45 : 0,
duration: 0,
})
} catch { /* empty bounds */ }
@@ -446,7 +418,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
try { map.remove() } catch { /* noop */ }
mapRef.current = null
}
}, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
// external activeMarkerId → highlight + flyTo
useEffect(() => {
@@ -459,15 +431,15 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({
center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 12),
pitch: enableMapbox3d && fullScreen ? 45 : 0,
pitch: mapbox3d && fullScreen ? 45 : 0,
duration: 500,
})
} catch { /* map not ready */ }
}, 50)
return () => clearTimeout(t)
}, [activeMarkerId, highlightMarker, enableMapbox3d, fullScreen])
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
if (!isMapLibre && !mapboxToken) {
if (!mapboxToken) {
return (
<div
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
+4 -10
View File
@@ -1,21 +1,15 @@
import { useEffect, useState } from 'react'
import { Navigation } from 'lucide-react'
export interface CompassMap {
getBearing: () => number
on: (type: 'rotate', listener: () => void) => unknown
off: (type: 'rotate', listener: () => void) => unknown
easeTo: (options: { bearing: number; pitch: number; duration: number }) => unknown
}
import type mapboxgl from 'mapbox-gl'
/**
* Round compass pill for the GL planner map. The map can be rotated and
* Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
* pitched, so this shows the current bearing (the arrow points to north) and snaps
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
* (GL only) and built as the SAME frosted shell (padding 4 around a 34px button)
* (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
* so its height and transparency match the POI pill exactly.
*/
export function MapCompassPill({ map }: { map: CompassMap }) {
export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
const [bearing, setBearing] = useState(() => map.getBearing())
useEffect(() => {
+4 -19
View File
@@ -1,36 +1,21 @@
import { lazy, Suspense } from 'react'
import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView'
// MapLibre/Mapbox pull in a ~230 KB (gzip) GL engine. Lazy-load the GL renderer so
// Leaflet-only installs never download it — it ships only once a GL provider is picked.
const MapViewGL = lazy(() => import('./MapViewGL').then(m => ({ default: m.MapViewGL })))
import { MapViewGL } from './MapViewGL'
// Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
//
// Offline maps: only the Leaflet renderer supports full pre-download (raster
// tiles via sync/tilePrefetcher.ts). GL maps are best-effort offline — their
// tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its
// vector tiles are cached opportunistically by the Service Worker as you view
// them online (see the GL tile rules in vite.config.js), not prefetched.
// them online (see the mapbox-tiles rule in vite.config.js), not prefetched.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) {
const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token)
// Fall back to Leaflet when Mapbox is selected but no token is set,
// so trip planner never shows an empty map due to a missing token.
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl'
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
: null
if (glProvider) {
// Render the previous Leaflet map as the fallback so there's no blank flash
// while the GL chunk loads on first use.
return (
<Suspense fallback={<MapView {...props} />}>
<MapViewGL {...props} glProvider={glProvider} />
</Suspense>
)
}
if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
return <MapView {...props} />
}
@@ -58,35 +58,6 @@ vi.mock('mapbox-gl', () => ({
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
vi.mock('maplibre-gl', () => ({
default: {
Map: vi.fn(function () {
return glMap
}),
Marker: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
}
}),
LngLatBounds: vi.fn(function () {
return { extend: vi.fn().mockReturnThis() }
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}
}),
},
}))
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false),
supportsCustom3d: vi.fn(() => false),
@@ -206,25 +177,4 @@ describe('MapViewGL', () => {
await act(async () => {})
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
})
it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => {
const mapboxgl = (await import('mapbox-gl')).default
const maplibregl = (await import('maplibre-gl')).default
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'maplibre-gl',
mapbox_access_token: '', // MapLibre/OpenFreeMap is tokenless — must not short-circuit
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
},
} as any)
const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })]
render(<MapViewGL places={places} fitKey={1} glProvider="maplibre-gl" />)
await act(async () => {})
// The MapLibre engine builds the map even without a token; Mapbox is not used.
expect(maplibregl.Map).toHaveBeenCalled()
expect(mapboxgl.Map).not.toHaveBeenCalled()
})
})
+39 -71
View File
@@ -1,9 +1,7 @@
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -11,7 +9,6 @@ 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 { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types'
@@ -57,9 +54,7 @@ interface Props {
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
glProvider?: GlMapProvider
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onMapReady?: (map: any | null) => void
onMapReady?: (map: mapboxgl.Map | null) => void
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -96,8 +91,8 @@ function createMarkerElement(place: Place & { category_color?: string; category_
}
const wrap = document.createElement('div')
// Do NOT set `position: relative` here — GL map libraries ship
// marker classes with `position: absolute` and rely on it. An inline
// Do NOT set `position: relative` here — mapbox-gl ships
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline
// `position: relative` here overrides the class, turns every marker into
// a static block element, and stacks them in document order inside the
// canvas container. The result looks exactly like "markers drift as the
@@ -174,40 +169,29 @@ export function MapViewGL({
pois = [],
onPoiClick,
onViewportChange,
glProvider = 'mapbox-gl',
onMapReady,
}: Props) {
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const mapLang = useSettingsStore(s => s.settings.language)
const isMapLibre = glProvider === 'maplibre-gl'
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const enableMapbox3d = !isMapLibre && mapbox3d
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const [mapReady, setMapReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, any>>(new Map())
const mapRef = useRef<mapboxgl.Map | null>(null)
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poiMarkersRef = useRef<any[]>([])
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
// Single reusable hover popup (name/category/address card) shared by planned
// places and POI markers — mirrors the Leaflet map's hover tooltip.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const popupRef = useRef<any | null>(null)
const popupRef = useRef<mapboxgl.Popup | null>(null)
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
const onViewportChangeRef = useRef(onViewportChange)
@@ -220,25 +204,23 @@ export function MapViewGL({
onClickRefs.current.map = onMapClick
onClickRefs.current.context = onMapContextMenu
// Build/rebuild the map on provider/style/token/3d change
// Build/rebuild the map on style/token/3d change
useEffect(() => {
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
if (!containerRef.current || !mapboxToken) return
mapboxgl.accessToken = mapboxToken
const mapOptions: Record<string, unknown> = {
const map = new mapboxgl.Map({
container: containerRef.current,
style: glStyle,
style: mapboxStyle,
center: [center[1], center[0]],
zoom,
pitch: enableMapbox3d ? 45 : 0,
pitch: mapbox3d ? 45 : 0,
attributionControl: true,
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
const map = new gl.Map(mapOptions as any)
projection: mapboxQuality ? 'globe' : 'mercator',
})
mapRef.current = map
popupRef.current = new gl.Popup({
popupRef.current = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
offset: 18,
@@ -252,12 +234,12 @@ export function MapViewGL({
;(window as any).__trek_map = map
map.on('load', () => {
if (enableMapbox3d) {
if (mapbox3d) {
// Terrain is only valuable on satellite styles — on clean vector
// styles it makes route lines drift off the HTML markers because
// the lines snap to DEM height while markers stay at sea level.
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
if (supportsCustom3d(glStyle)) {
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
if (supportsCustom3d(mapboxStyle)) {
const dark = document.documentElement.classList.contains('dark')
addCustom3dBuildings(map, dark)
}
@@ -270,7 +252,7 @@ export function MapViewGL({
// non-satellite Standard style still looks great without terrain,
// so flatten it out to keep markers pinned. (Satellite variants
// are left alone — the DEM is what gives them their character.)
if (glStyle === MAPBOX_DEFAULT_STYLE) {
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
try { map.setTerrain(null) } catch { /* noop */ }
}
// initial route source — kept around so updates can setData() cheaply
@@ -316,7 +298,7 @@ export function MapViewGL({
map.on('click', (e) => {
const t = e.originalEvent.target as HTMLElement
if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
})
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
@@ -327,7 +309,7 @@ export function MapViewGL({
}
map.on('moveend', emitViewport)
map.once('idle', emitViewport)
// In the GL map the right mouse button is reserved for the
// In the mapbox-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.
const canvas = map.getCanvasContainer()
@@ -374,9 +356,7 @@ export function MapViewGL({
const ll = marker.getLngLat()
let alt = 0
try {
const e = typeof map.queryTerrainElevation === 'function'
? map.queryTerrainElevation([ll.lng, ll.lat])
: null
const e = map.queryTerrainElevation([ll.lng, ll.lat])
if (typeof e === 'number' && Number.isFinite(e)) alt = e
} catch { /* terrain not ready */ }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -388,9 +368,7 @@ export function MapViewGL({
}
})
}
// Terrain altitude sync only matters with mapbox 3D/terrain on; skip the per-frame
// listener entirely for MapLibre and flat mapbox styles.
if (enableMapbox3d) map.on('render', syncMarkerAltitudes)
map.on('render', syncMarkerAltitudes)
return () => {
canvas.removeEventListener('mousedown', onAuxDown)
@@ -411,17 +389,7 @@ export function MapViewGL({
mapRef.current = null
setMapReady(false)
}
}, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only
// Pin the basemap label language to the UI language so labels don't fall back to the
// browser/OS locale and stack multiple scripts per place (e.g. "India/भारत/India", #1299).
// Mapbox Standard exposes this via a basemap config property; classic and MapLibre styles
// are left as-is. Runs on load (mapReady) and whenever the UI language changes.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || isMapLibre || !isStandardFamily(glStyle)) return
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support the basemap language property */ }
}, [mapLang, mapReady, isMapLibre, glStyle])
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
// simultaneous thumb arrivals into one re-render.
@@ -521,12 +489,12 @@ export function MapViewGL({
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
// but it rotates the element by the pitch angle and visually offsets
// the anchor by ~100px at 45° tilt, which caused the observed drift.
const m = new gl.Marker({ element: el, anchor: 'center' })
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([place.lng, place.lat])
.addTo(map)
markersRef.current.set(place.id, m)
})
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them).
@@ -543,10 +511,10 @@ export function MapViewGL({
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
poiMarkersRef.current.push(m)
}
}, [pois, mapReady, glProvider])
}, [pois, mapReady])
// Update route geojson
useEffect(() => {
@@ -610,7 +578,7 @@ export function MapViewGL({
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
}, gl.Marker as any)
})
}
reservationOverlayRef.current.update(visibleReservations, {
showConnections: true,
@@ -618,7 +586,7 @@ export function MapViewGL({
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider])
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
// Fit bounds on fitKey change — matches the Leaflet BoundsController
const paddingOpts = useMemo(() => {
@@ -638,14 +606,14 @@ export function MapViewGL({
const target = dayPlaces.length > 0 ? dayPlaces : places
const valid = target.filter(p => p.lat && p.lng)
if (valid.length === 0) return
const bounds = new gl.LngLatBounds()
const bounds = new mapboxgl.LngLatBounds()
valid.forEach(p => bounds.extend([p.lng, p.lat]))
const run = () => {
try {
map.fitBounds(bounds, {
padding: paddingOpts,
maxZoom: 15,
pitch: enableMapbox3d ? 45 : 0,
pitch: mapbox3d ? 45 : 0,
duration: 400,
})
} catch { /* noop */ }
@@ -664,7 +632,7 @@ export function MapViewGL({
map.flyTo({
center: [target.lng, target.lat],
zoom: Math.max(map.getZoom(), 14),
pitch: enableMapbox3d ? 45 : 0,
pitch: mapbox3d ? 45 : 0,
duration: 400,
// Account for the side panels and the bottom inspector / day-detail panel
// so the selected pin lands in the centre of the *visible* map area rather
@@ -672,7 +640,7 @@ export function MapViewGL({
padding: paddingOpts,
})
} catch { /* noop */ }
}, [selectedPlaceId, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
// External center/zoom prop changes — jump without animation
useEffect(() => {
@@ -695,7 +663,7 @@ export function MapViewGL({
}
if (!userPosition) return
const apply = () => {
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map, gl.Marker as any)
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
locationMarkerRef.current.update(userPosition)
if (trackingMode === 'follow') {
// easeTo is gentler than flyTo for continuous updates
@@ -711,9 +679,9 @@ export function MapViewGL({
}
if (map.loaded()) apply()
else map.once('load', apply)
}, [userPosition, trackingMode, glProvider])
}, [userPosition, trackingMode])
if (!isMapLibre && !mapboxToken) {
if (!mapboxToken) {
return (
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
<div className="text-sm text-zinc-500">
@@ -6,7 +6,6 @@ import {
calculateSegments,
optimizeRoute,
generateGoogleMapsUrl,
withHotelBookends,
} from './RouteCalculator'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -242,46 +241,3 @@ describe('generateGoogleMapsUrl', () => {
expect(result).toContain('48.86,2.36')
})
})
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
describe('withHotelBookends', () => {
const hotel = { lat: 1, lng: 1 }
const a = { lat: 2, lng: 2 }
const b = { lat: 3, lng: 3 }
const evening = { lat: 4, lng: 4 }
it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([
[hotel, a],
[a, b],
[b, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => {
expect(withHotelBookends([], a, a, hotel, evening)).toEqual([
[hotel, a],
[a, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([
[hotel, a],
[a, b],
])
})
})
+8 -38
View File
@@ -1,6 +1,4 @@
import { useSettingsStore } from '../../store/settingsStore'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
import { formatDistance } from '../../utils/units'
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -62,34 +60,13 @@ export async function calculateRoute(
coordinates,
distance,
duration,
distanceText: formatRouteDistance(distance),
distanceText: formatDistance(distance),
durationText: formatDuration(duration),
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(drivingDuration),
}
}
/**
* Prepends a hotelfirst-waypoint run and appends a last-waypointhotel run to the
* day's activity runs, so the drawn route starts and ends at the day's accommodation
* (matching the sidebar's hotel connectors). A bookend is only added when both its
* hotel and the first/last located waypoint exist; passing nulls leaves `runs`
* untouched. The shared first/last waypoint is repeated so the polylines join.
*/
export function withHotelBookends(
runs: Waypoint[][],
firstWay: Waypoint | undefined,
lastWay: Waypoint | undefined,
startHotel: Waypoint | null,
endHotel: Waypoint | null,
): Waypoint[][] {
const out: Waypoint[][] = []
if (startHotel && firstWay) out.push([startHotel, firstWay])
out.push(...runs)
if (endHotel && lastWay) out.push([lastWay, endHotel])
return out
}
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length === 0) return null
@@ -220,7 +197,7 @@ export async function calculateSegments(
duration: leg.duration,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration),
distanceText: formatRouteDistance(leg.distance),
distanceText: formatDistance(leg.distance),
}
})
}
@@ -240,9 +217,7 @@ export async function calculateRouteWithLegs(
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
// The cached result carries formatted leg distances, so the active distance unit is
// part of the key — otherwise switching km↔mi would return stale text (#1300).
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}`
const cacheKey = `${profile}:${coords}`
const cached = routeCache.get(cacheKey)
if (cached) return cached
@@ -269,7 +244,7 @@ export async function calculateRouteWithLegs(
duration: leg.duration,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration),
distanceText: formatRouteDistance(leg.distance),
distanceText: formatDistance(leg.distance),
durationText: formatDuration(leg.duration),
}
}
@@ -284,16 +259,11 @@ export async function calculateRouteWithLegs(
return result
}
function getDistanceUnit(): DistanceUnit {
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
}
function formatRouteDistance(meters: number): string {
const unit = getDistanceUnit()
if (unit === 'metric' && meters < 1000) {
function formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)} m`
}
return formatDistance(meters / 1000, unit)
return `${(meters / 1000).toFixed(1)} km`
}
function formatDuration(seconds: number): string {
@@ -1,72 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
MAPBOX_DEFAULT_STYLE,
OPENFREEMAP_DEFAULT_STYLE,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleForActiveProvider,
basemapLanguage,
} from './glProviders'
describe('glProviders', () => {
it('keeps OpenFreeMap styles for MapLibre', () => {
const style = 'https://tiles.openfreemap.org/styles/bright'
expect(normalizeStyleForProvider('maplibre-gl', style)).toBe(style)
})
it('falls back to OpenFreeMap for MapLibre styles outside the CSP allowlist', () => {
expect(normalizeStyleForProvider('maplibre-gl', 'https://demotiles.maplibre.org/style.json')).toBe(
OPENFREEMAP_DEFAULT_STYLE,
)
expect(normalizeStyleForProvider('maplibre-gl', MAPBOX_DEFAULT_STYLE)).toBe(OPENFREEMAP_DEFAULT_STYLE)
})
it('leaves Mapbox styles unchanged for Mapbox GL', () => {
expect(normalizeStyleForProvider('mapbox-gl', MAPBOX_DEFAULT_STYLE)).toBe(MAPBOX_DEFAULT_STYLE)
})
it('matches the OpenFreeMap CSP host', () => {
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org/styles/liberty')).toBe(true)
expect(isOpenFreeMapStyle('https://demotiles.maplibre.org/style.json')).toBe(false)
})
it('rejects host/userinfo spoofing and http downgrade', () => {
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org.evil.com/styles/x')).toBe(false)
expect(isOpenFreeMapStyle('https://evil.com/@tiles.openfreemap.org/styles/x')).toBe(false)
expect(isOpenFreeMapStyle('http://tiles.openfreemap.org/styles/liberty')).toBe(false)
expect(isOpenFreeMapStyle(' https://tiles.openfreemap.org/styles/liberty ')).toBe(true)
})
it('falls back to provider defaults for empty/whitespace styles', () => {
expect(normalizeStyleForProvider('maplibre-gl', '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
expect(normalizeStyleForProvider('maplibre-gl', ' ')).toBe(OPENFREEMAP_DEFAULT_STYLE)
expect(normalizeStyleForProvider('mapbox-gl', '')).toBe(MAPBOX_DEFAULT_STYLE)
expect(normalizeStyleForProvider('mapbox-gl', null)).toBe(MAPBOX_DEFAULT_STYLE)
})
it('styleForActiveProvider reads each provider\'s own style slot', () => {
const mb = 'mapbox://styles/me/custom'
const ofm = 'https://tiles.openfreemap.org/styles/bright'
expect(styleForActiveProvider('mapbox-gl', mb, ofm)).toBe(mb)
expect(styleForActiveProvider('maplibre-gl', mb, ofm)).toBe(ofm)
// An empty MapLibre slot falls back to the OpenFreeMap default, leaving mapbox untouched.
expect(styleForActiveProvider('maplibre-gl', mb, '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
})
it('basemapLanguage maps TREK UI codes to basemap label codes (#1299)', () => {
// Pass-through for plain ISO 639-1 codes.
expect(basemapLanguage('en')).toBe('en')
expect(basemapLanguage('de')).toBe('de')
expect(basemapLanguage('fr')).toBe('fr')
// TREK-specific overrides.
expect(basemapLanguage('br')).toBe('pt')
expect(basemapLanguage('gr')).toBe('el')
expect(basemapLanguage('zh')).toBe('zh-Hans')
expect(basemapLanguage('zhTw')).toBe('zh-Hant')
expect(basemapLanguage('zh-TW')).toBe('zh-Hant')
// Falls back to English when unset.
expect(basemapLanguage(undefined)).toBe('en')
expect(basemapLanguage('')).toBe('en')
})
})
-87
View File
@@ -1,87 +0,0 @@
export type GlMapProvider = 'mapbox-gl' | 'maplibre-gl'
export interface GlStylePreset {
name: string
url: string
tags?: string[]
}
export const MAPBOX_DEFAULT_STYLE = 'mapbox://styles/mapbox/standard'
export const OPENFREEMAP_DEFAULT_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
export const MAPBOX_STYLE_PRESETS: GlStylePreset[] = [
{ name: 'Mapbox Standard', url: MAPBOX_DEFAULT_STYLE, tags: ['3D', 'Apple-like'] },
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
]
export const OPENFREEMAP_STYLE_PRESETS: GlStylePreset[] = [
{ name: 'OpenFreeMap Liberty', url: OPENFREEMAP_DEFAULT_STYLE, tags: ['OpenFreeMap', '2D'] },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright', tags: ['OpenFreeMap', 'Classic'] },
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron', tags: ['OpenFreeMap', 'Minimal'] },
]
export function getStylePresets(provider: GlMapProvider): GlStylePreset[] {
return provider === 'maplibre-gl' ? OPENFREEMAP_STYLE_PRESETS : MAPBOX_STYLE_PRESETS
}
export function defaultStyleForProvider(provider: GlMapProvider): string {
return provider === 'maplibre-gl' ? OPENFREEMAP_DEFAULT_STYLE : MAPBOX_DEFAULT_STYLE
}
export function isOpenFreeMapStyle(style?: string | null): boolean {
return (style || '').trim().startsWith('https://tiles.openfreemap.org/')
}
export function normalizeStyleForProvider(provider: GlMapProvider, style?: string | null): string {
const trimmed = (style || '').trim()
if (!trimmed) return defaultStyleForProvider(provider)
if (provider === 'maplibre-gl') {
return isOpenFreeMapStyle(trimmed) ? trimmed : OPENFREEMAP_DEFAULT_STYLE
}
return trimmed
}
/** The settings key that holds the style for a given GL provider. */
export function styleSettingKey(provider: GlMapProvider): 'mapbox_style' | 'maplibre_style' {
return provider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
}
/**
* Each GL provider keeps its style in its own slot (mapbox_style / maplibre_style), so
* switching providers never overwrites the other one's custom style. Picks and normalizes
* the style for the active provider.
*/
export function styleForActiveProvider(
provider: GlMapProvider,
mapboxStyle?: string | null,
maplibreStyle?: string | null,
): string {
return normalizeStyleForProvider(provider, provider === 'maplibre-gl' ? maplibreStyle : mapboxStyle)
}
// A few TREK UI language codes differ from what the GL basemap expects for its labels.
const BASEMAP_LANG_OVERRIDES: Record<string, string> = {
br: 'pt', // TREK 'br' = Brazilian Portuguese
gr: 'el', // TREK 'gr' = Greek
zh: 'zh-Hans',
zhTw: 'zh-Hant',
'zh-TW': 'zh-Hant',
}
/**
* Maps a TREK UI language code to the label language the GL basemap expects. Used to pin
* Mapbox Standard's basemap labels to the user's language so they don't fall back to the
* browser/OS locale and stack multiple scripts per place (#1299).
*/
export function basemapLanguage(uiLang: string | undefined): string {
const code = (uiLang || 'en').trim()
return BASEMAP_LANG_OVERRIDES[code] ?? code
}
@@ -1,13 +1,6 @@
import type mapboxgl from 'mapbox-gl'
import mapboxgl from 'mapbox-gl'
import type { GeoPosition } from '../../hooks/useGeolocation'
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => {
setLngLat: (lngLat: mapboxgl.LngLatLike) => { addTo: (map: mapboxgl.Map) => unknown }
addTo: (map: mapboxgl.Map) => unknown
remove: () => void
getElement: () => HTMLElement
}
// Build the DOM element that backs the mapbox Marker. We animate the
// heading cone via a CSS rotation so the DOM stays stable across updates
// and mapbox doesn't get confused about which element to position.
@@ -73,10 +66,10 @@ export interface LocationMarkerHandle {
// mapbox map. Returns a handle the caller uses to push position updates
// and clean up. Keeps its own DOM element and GeoJSON source so it can
// coexist with the regular trip markers.
export function attachLocationMarker(map: mapboxgl.Map, MarkerCtor: MarkerConstructor): LocationMarkerHandle {
export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle {
ensurePulseStyle()
const { root, cone } = buildLocationEl()
const marker = new MarkerCtor({ element: root, anchor: 'center' })
const marker = new mapboxgl.Marker({ element: root, anchor: 'center' })
const ensureAccuracyLayer = () => {
if (map.getSource('trek-location-accuracy')) return
@@ -8,7 +8,7 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import type mapboxgl from 'mapbox-gl'
import mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types'
@@ -220,29 +220,18 @@ export interface ReservationOverlayOptions {
onEndpointClick?: (reservationId: number) => void
}
type GlMarker = {
setLngLat: (lngLat: mapboxgl.LngLatLike) => GlMarker
addTo: (map: mapboxgl.Map) => GlMarker
remove: () => void
getElement: () => HTMLElement
}
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => GlMarker
export class ReservationMapboxOverlay {
private map: mapboxgl.Map
private items: TransportItem[] = []
private opts: ReservationOverlayOptions
private MarkerCtor: MarkerConstructor
private endpointMarkers: GlMarker[] = []
private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
private endpointMarkers: mapboxgl.Marker[] = []
private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
private rerender: () => void
private destroyed = false
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions, MarkerCtor: MarkerConstructor) {
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
this.map = map
this.opts = opts
this.MarkerCtor = MarkerCtor
this.rerender = () => { if (!this.destroyed) this.render() }
this.setupLayer()
map.on('zoomend', this.rerender)
@@ -361,7 +350,7 @@ export class ReservationMapboxOverlay {
this.opts.onEndpointClick?.(item.res.id)
})
}
const marker = new this.MarkerCtor({ element: node, anchor: 'center' })
const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
.setLngLat([ep.lng, ep.lat])
.addTo(map)
this.endpointMarkers.push(marker)
-22
View File
@@ -323,28 +323,6 @@ describe('downloadTripPDF', () => {
expect(photoCalled).toBe(true)
})
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
let fetchedId: string | null = null
server.use(
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
fetchedId = params.placeId as string
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
}),
)
// The assignment projection drops osm_id; the full place in `places` carries it.
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
const args = {
...richArgs,
places: [osmPlace],
assignments: {
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
} as any,
}
await downloadTripPDF(args)
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
expect(fetchedId).toBe('node/240109189')
})
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
const args = {
...minimalArgs,
+10 -18
View File
@@ -97,29 +97,21 @@ function dayCost(assignments, dayId, locale) {
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
}
// Pre-fetch place photos for all assigned places.
// Assignment places are a server-side projection that drops osm_id, so we recover
// the full place from the trip's places pool and key the photo off the same id the
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
// places fell back to category icons in the PDF even though they show photos in-app.
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
// Pre-fetch Google Place photos for all assigned places
async function fetchPlacePhotos(assignments: AssignmentsMap) {
const photoMap = {} // placeId → photoUrl
// The assignment projection drops osm_id, so recover it from the full places pool.
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
const toFetch = unique
.map(p => ({ p, osm_id: osmById.get(p.id) }))
.filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null)))
// Assignment places are a server-side projection that omits osm_id, so photo
// pre-fetch keys off the google_place_id that the projection does carry.
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
await Promise.allSettled(
toFetch.map(async ({ p, osm_id }) => {
// Same key the app UI uses: google_place_id || osm_id || coords.
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
toFetch.map(async (place) => {
try {
const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name)
if (data.photoUrl) photoMap[p.id] = data.photoUrl
const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
if (data.photoUrl) photoMap[place.id] = data.photoUrl
} catch {}
})
)
@@ -149,8 +141,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
const accommodations = await accommodationsApi.list(trip.id);
// Pre-fetch place photos (Google, OSM and coords-only places)
const photoMap = await fetchPlacePhotos(assignments, places)
// Pre-fetch place photos from Google
const photoMap = await fetchPlacePhotos(assignments)
const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
@@ -174,9 +174,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
const user = userEvent.setup();
// Uncategorized item: deleting it is a plain DELETE (a custom category's last
// item is instead converted to a placeholder — see FE-COMP-PACKING-070).
const item = buildPackingItem({ id: 99, name: 'To Remove', category: null });
const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' });
let deleteCalled = false;
server.use(
http.delete('/api/trips/1/packing/99', () => {
@@ -1417,83 +1415,4 @@ describe('PackingListPanel', () => {
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
it('FE-COMP-PACKING-070: deleting the last item of a custom category converts the row to a placeholder so the category persists in place (#1289)', async () => {
const user = userEvent.setup();
const item = buildPackingItem({ id: 99, name: 'Tent', category: 'Camping Gear' });
// handleDeleteItem decides "last in category" from the rendered list.
seedStore(useTripStore, { packingItems: [item] });
let deleted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.delete('/api/trips/1/packing/99', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/99', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[item]} />);
await user.click(screen.getByTitle('Delete'));
// The row is updated in place (same id) rather than deleted, so colour/position hold.
await waitFor(() => expect(putBody).toMatchObject({ name: '...' }));
expect(deleted).toBe(false);
});
it('FE-COMP-PACKING-071: deleting the placeholder row deletes it, dismissing the empty category (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let deleted = false;
let converted = false;
server.use(
http.delete('/api/trips/1/packing/5', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/5', () => {
converted = true;
return HttpResponse.json({ item: placeholder });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
await user.click(screen.getByTitle('Delete'));
await waitFor(() => expect(deleted).toBe(true));
// It is the placeholder itself — it must be removed, not re-converted.
expect(converted).toBe(false);
});
it('FE-COMP-PACKING-072: adding an item to an empty category reuses the placeholder row instead of appending (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let posted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/packing', () => {
posted = true;
return HttpResponse.json({ item: buildPackingItem({ id: 6 }) });
}),
http.put('/api/trips/1/packing/5', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
// Open the category's inline "Add item" and add a real entry.
await user.click(screen.getByText('Add item'));
const input = await screen.findByPlaceholderText('Item name...');
await user.type(input, 'Tent');
await user.keyboard('{Enter}');
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
expect(posted).toBe(false);
});
});
@@ -18,7 +18,6 @@ interface KategorieGruppeProps {
allCategories: string[]
onRename: (oldName: string, newName: string) => Promise<void>
onDeleteAll: (items: PackingItem[]) => Promise<void>
onDeleteItem: (item: PackingItem) => Promise<void>
onAddItem: (category: string, name: string) => Promise<void>
assignees: CategoryAssignee[]
tripMembers: TripMember[]
@@ -29,7 +28,7 @@ interface KategorieGruppeProps {
canEdit?: boolean
}
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
@@ -232,7 +231,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
))}
{/* Inline add item */}
{canEdit && (showAddItem ? (
@@ -15,14 +15,13 @@ interface ArtikelZeileProps {
tripId: number
categories: string[]
onCategoryChange: () => void
onDelete?: (item: PackingItem) => Promise<void>
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
@@ -44,9 +43,6 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
}
const handleDelete = async () => {
// The panel routes deletion through onDelete so an emptied custom category
// keeps its placeholder; fall back to a plain delete when used standalone.
if (onDelete) { await onDelete(item); return }
try { await deletePackingItem(tripId, item.id) }
catch { toast.error(t('packing.toast.deleteError')) }
}
@@ -4,7 +4,7 @@ import { KategorieGruppe } from './PackingListPanelCategoryGroup'
export function PackingList(S: PackingState) {
const {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
} = S
@@ -31,7 +31,6 @@ export function PackingList(S: PackingState) {
allCategories={allCategories}
onRename={handleRenameCategory}
onDeleteAll={handleDeleteCategory}
onDeleteItem={handleDeleteItem}
onAddItem={handleAddItemToCategory}
assignees={categoryAssignees[kat] || []}
tripMembers={tripMembers}
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
import { packingApi, tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import type { PackingItem, PackingBag } from '../../types'
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { BAG_COLORS } from './packingListPanel.constants'
import { parseImportLines } from './packingListPanel.helpers'
export interface TripMember {
@@ -44,7 +44,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
@@ -106,45 +106,10 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const handleAddItemToCategory = async (category: string, name: string) => {
try {
// Reuse the '...' placeholder slot when the category already has one, so a
// freshly-emptied category keeps its position (and therefore its colour)
// instead of the new item being appended to the end of the list.
const placeholder = useTripStore.getState().packingItems.find(
i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME
)
if (placeholder) {
await updatePackingItem(tripId, placeholder.id, { name })
} else {
await addPackingItem(tripId, { name, category })
}
await addPackingItem(tripId, { name, category })
} catch { toast.error(t('packing.toast.addError')) }
}
// Deleting an item from a row. When it is the last item of a user-created
// category, turn that row back into the '...' placeholder in place rather than
// deleting it (#1289). Updating the row keeps its id, list position and colour,
// so the category neither disappears nor jumps to the end. The default
// (uncategorized) group and the placeholder row itself are deleted normally —
// removing the placeholder is how an empty category is dismissed.
const handleDeleteItem = async (item: PackingItem) => {
const category = item.category
const isLastInCategory = !!category
&& item.name !== PACKING_PLACEHOLDER_NAME
&& !items.some(i => i.id !== item.id && i.category === category)
try {
if (isLastInCategory) {
if (item.checked) await togglePackingItem(tripId, item.id, false)
await updatePackingItem(tripId, item.id, {
name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1,
})
} else {
await deletePackingItem(tripId, item.id)
}
} catch {
toast.error(t('packing.toast.deleteError'))
}
}
const handleAddNewCategory = async () => {
if (!newCatName.trim()) return
let catName = newCatName.trim()
@@ -343,7 +308,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
@@ -1,82 +0,0 @@
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { formatMoney } from '../../utils/formatters'
import { catMeta } from '../Budget/costsCategories'
import type { BudgetItem } from '../../types'
/**
* The Costs block inside a booking modal. Replaces the old inline price + budget
* category fields: when no expense is linked yet it offers a "create expense"
* button (the modal saves the booking first, then opens the full Costs editor);
* once linked it shows the expense with edit / remove actions.
*/
export function BookingCostsSection({ reservationId, pendingExpense, onCreate, onEdit, onRemove }: {
reservationId: number | null
/** A cost parsed from an import that will be linked on save — previewed before the booking exists. */
pendingExpense?: { total_price: number; currency?: string | null; category: string } | null
onCreate: () => void
onEdit: (item: BudgetItem) => void
onRemove: (item: BudgetItem) => void
}) {
const { t, locale } = useTranslation()
const budgetItems = useTripStore(s => s.budgetItems)
const trip = useTripStore(s => s.trip)
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
// Import review (booking not saved yet): preview the parsed cost that will be linked on save.
if (!linked && pendingExpense && pendingExpense.total_price > 0) {
const meta = catMeta(pendingExpense.category)
const Icon = meta.Icon
return (
<div>
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{t(meta.labelKey)}</div>
<div className="text-content-faint" style={{ fontSize: 12 }}>{t('reservations.createExpenseHint')}</div>
</div>
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)}</span>
</div>
</div>
)
}
if (linked) {
const meta = catMeta(linked.category)
const Icon = meta.Icon
return (
<div>
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
<div className="text-content-faint" style={{ fontSize: 12 }}>{t(meta.labelKey)}</div>
</div>
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
</div>
</div>
)
}
return (
<div>
<label className={labelCls}>{t('reservations.costsLabel')}</label>
<button type="button" onClick={onCreate}
className="bg-surface-secondary border border-edge text-content"
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 13.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={15} /> {t('reservations.createExpense')}
</button>
<div className="text-content-faint" style={{ fontSize: 11, marginTop: 6 }}>{t('reservations.createExpenseHint')}</div>
</div>
)
}
@@ -1,11 +0,0 @@
import type { BudgetItem } from '../../types'
/**
* A request from a booking modal to open the Costs expense editor either to
* edit the already-linked expense, or to create a new one prefilled from the
* booking (the modal saves the booking first so `reservationId` is known).
*/
export interface BookingExpenseRequest {
editItem?: BudgetItem
prefill?: { reservationId?: number; name?: string; category?: string; amount?: number }
}
@@ -1,44 +1,81 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useRef, useEffect } from 'react'
import { Upload, X } from 'lucide-react'
import { Upload, Plane, Train, Hotel, UtensilsCrossed, Car, Anchor, Calendar, ArrowLeft, X } from 'lucide-react'
import type { BookingImportPreviewItem } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { reservationsApi, healthApi } from '../../api/client'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
import { saveImportFiles } from '../../db/offlineDb'
import { useToast } from '../shared/Toast'
import { reservationsApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
interface BookingImportModalProps {
isOpen: boolean
onClose: () => void
tripId: number
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
}
const ACCEPTED_EXTS = ['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']
const MAX_FILE_BYTES = 10 * 1024 * 1024
const MAX_FILES = 5
/**
* Upload booking files and kick off a BACKGROUND parse. The modal closes at once;
* the parse runs server-side and is tracked by the global BackgroundTasksWidget
* (progress over the WebSocket). When it finishes, the trip page opens the per-item
* review flow so the user can navigate and keep editing while it works.
*/
export default function BookingImportModal({ isOpen, onClose, tripId }: BookingImportModalProps) {
const TYPE_ICONS: Record<string, React.FC<{ size: number; color?: string }>> = {
flight: Plane,
train: Train,
hotel: Hotel,
restaurant: UtensilsCrossed,
car: Car,
cruise: Anchor,
event: Calendar,
}
function typeColor(type: string): string {
const map: Record<string, string> = {
flight: '#3b82f6',
train: '#10b981',
hotel: '#8b5cf6',
restaurant: '#f59e0b',
car: '#6b7280',
cruise: '#06b6d4',
event: '#ec4899',
}
return map[type] ?? 'var(--text-faint)'
}
function formatDateTime(iso: unknown): string {
if (!iso) return ''
const str = typeof iso === 'string' ? iso : typeof iso === 'object' ? JSON.stringify(iso) : String(iso)
const date = str.slice(0, 10)
const time = str.length > 10 ? str.slice(11, 16) : ''
return [date, time].filter(Boolean).join(' ')
}
export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }: BookingImportModalProps) {
const { t } = useTranslation()
const addTask = useBackgroundTasksStore((s) => s.addTask)
const toast = useToast()
const loadTrip = useTripStore((s) => s.loadTrip)
const fileInputRef = useRef<HTMLInputElement>(null)
const mouseDownTarget = useRef<EventTarget | null>(null)
type Phase = 'upload' | 'preview' | 'confirming'
const [phase, setPhase] = useState<Phase>('upload')
const [files, setFiles] = useState<File[]>([])
const [isDragOver, setIsDragOver] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [aiParsing, setAiParsing] = useState(false)
const [previewItems, setPreviewItems] = useState<BookingImportPreviewItem[]>([])
const [warnings, setWarnings] = useState<string[]>([])
const [excluded, setExcluded] = useState<Set<number>>(() => new Set())
const reset = () => {
setPhase('upload')
setFiles([])
setIsDragOver(false)
setLoading(false)
setError('')
setPreviewItems([])
setWarnings([])
setExcluded(new Set())
}
useEffect(() => {
@@ -47,11 +84,6 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
useEffect(() => {
if (!isOpen) return
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
}, [isOpen])
const handleClose = () => { reset(); onClose() }
const validateFile = (f: File): string | null => {
@@ -89,44 +121,88 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
if (list.length) selectFiles(list)
}
// Start the parse in the background and close — the widget takes it from here.
const handleParse = async () => {
if (files.length === 0 || loading) return
setLoading(true)
setError('')
try {
const mode = aiParsing ? 'fallback-on-empty' : 'no-ai'
const { jobId } = await reservationsApi.importBookingAsync(tripId, files, mode)
// Keep the uploaded files so the review can attach each source document to its booking —
// in memory for the immediate path, and in IndexedDB so it survives a reload mid-parse.
await saveImportFiles(jobId, files)
addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length, files })
handleClose()
const result = await reservationsApi.importBookingPreview(tripId, files)
setPreviewItems(result.items ?? [])
setWarnings(result.warnings ?? [])
setExcluded(new Set())
setPhase('preview')
} catch (err: any) {
setError(err?.response?.data?.error ?? t('reservations.import.error'))
const msg = err?.response?.data?.error ?? t('reservations.import.error')
setError(msg)
} finally {
setLoading(false)
}
}
const handleConfirm = async () => {
const toImport = previewItems.filter((_, i) => !excluded.has(i))
if (toImport.length === 0) return
setPhase('confirming')
setError('')
try {
const result = await reservationsApi.importBookingConfirm(tripId, toImport)
const created = result.created ?? []
await loadTrip(tripId)
if (created.length > 0) {
pushUndo?.(t('undo.importBooking'), async () => {
try {
const { reservationsApi: rApi } = await import('../../api/client')
await Promise.all(created.map((r) => rApi.delete(tripId, r.id).catch(() => {})))
} catch {}
await loadTrip(tripId)
})
toast.success(t('reservations.import.success', { count: created.length }))
} else {
toast.warning(t('reservations.import.previewEmpty'))
}
handleClose()
} catch (err: any) {
setError(err?.response?.data?.error ?? t('reservations.import.error'))
setPhase('preview')
}
}
const toggleExclude = (idx: number) => {
setExcluded(prev => {
const next = new Set(prev)
if (next.has(idx)) next.delete(idx); else next.add(idx)
return next
})
}
const activeCount = previewItems.filter((_, i) => !excluded.has(i)).length
if (!isOpen) return null
return ReactDOM.createPortal(
<div
className="bg-[rgba(0,0,0,0.4)]"
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onMouseDown={(e) => { mouseDownTarget.current = e.target }}
onClick={(e) => {
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
mouseDownTarget.current = null
}}
>
<div
onClick={(e) => e.stopPropagation()}
onClick={e => e.stopPropagation()}
className="bg-surface-card"
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
{phase === 'preview' && (
<button onClick={() => setPhase('upload')} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
<ArrowLeft size={16} />
</button>
)}
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('reservations.import.title')}
</div>
@@ -136,45 +212,131 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
{t('reservations.import.acceptedFormats')}
</div>
{/* Upload phase */}
{phase === 'upload' && (
<>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
{t('reservations.import.acceptedFormats')}
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTS.join(',')}
multiple
style={{ display: 'none' }}
onChange={handleInputChange}
/>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTS.join(',')}
multiple
style={{ display: 'none' }}
onChange={handleInputChange}
/>
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
style={{
width: '100%', minHeight: 100, borderRadius: 12,
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
marginBottom: 12, padding: 16, boxSizing: 'border-box',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
{isDragOver ? (
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
) : files.length > 0 ? (
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map((f) => f.name).join(', ')}</span>
) : (
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
)}
</div>
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
style={{
width: '100%', minHeight: 100, borderRadius: 12,
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
marginBottom: 12, padding: 16, boxSizing: 'border-box',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
{isDragOver ? (
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
) : files.length > 0 ? (
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map(f => f.name).join(', ')}</span>
) : (
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
)}
</div>
</>
)}
{/* Preview phase */}
{(phase === 'preview' || phase === 'confirming') && (
<>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 10 }}>
{t('reservations.import.previewHeading', { count: previewItems.length })}
</div>
{previewItems.length === 0 && (
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
{t('reservations.import.previewEmpty')}
</div>
)}
{previewItems.map((item, idx) => {
const Icon = TYPE_ICONS[item.type] ?? Calendar
const isExcluded = excluded.has(idx)
const fromEp = item.endpoints?.find(e => e.role === 'from')
const toEp = item.endpoints?.find(e => e.role === 'to')
return (
<div
key={`${item.source.fileName}-${idx}`}
className={isExcluded ? 'bg-surface-tertiary' : 'bg-surface-secondary'}
style={{
borderRadius: 10, padding: '10px 12px', marginBottom: 8,
border: `1px solid ${isExcluded ? 'var(--border-faint)' : 'var(--border-primary)'}`,
opacity: isExcluded ? 0.5 : 1, transition: 'opacity 0.15s',
display: 'flex', gap: 10, alignItems: 'flex-start',
}}
>
<div style={{ flexShrink: 0, marginTop: 2 }}>
<Icon size={15} color={typeColor(item.type)} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.title}
</div>
{fromEp && toEp && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
{fromEp.code ?? fromEp.name} {toEp.code ?? toEp.name}
</div>
)}
{item.reservation_time && (
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{formatDateTime(item.reservation_time)}
{item.reservation_end_time && ` ${formatDateTime(item.reservation_end_time)}`}
</div>
)}
{item._accommodation?.check_in && (
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{formatDateTime(item._accommodation.check_in)} {formatDateTime(item._accommodation.check_out)}
</div>
)}
{item.confirmation_number && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'monospace' }}>
{item.confirmation_number}
</div>
)}
</div>
<button
onClick={() => toggleExclude(idx)}
className="bg-transparent text-content-faint"
style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, flexShrink: 0, fontSize: 11, fontFamily: 'inherit', fontWeight: 500 }}
title={t('reservations.import.removeItem')}
>
{isExcluded ? '' : <X size={12} />}
</button>
</div>
)
})}
</>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="bg-[rgba(245,158,11,0.08)] text-[#92400e]" style={{ border: '1px solid rgba(245,158,11,0.3)', borderRadius: 10, padding: '8px 10px', fontSize: 12, marginTop: 8, whiteSpace: 'pre-wrap' }}>
{warnings.join('\n')}
</div>
)}
{/* Error */}
{error && (
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
{error}
@@ -190,14 +352,28 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
>
{t('common.cancel')}
</button>
<button
onClick={handleParse}
disabled={files.length === 0 || loading}
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{loading ? t('reservations.import.parsing') : t('common.import')}
</button>
{phase === 'upload' && (
<button
onClick={handleParse}
disabled={files.length === 0 || loading}
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{loading ? t('reservations.import.parsing') : t('common.import')}
</button>
)}
{(phase === 'preview' || phase === 'confirming') && (
<button
onClick={handleConfirm}
disabled={activeCount === 0 || phase === 'confirming'}
className={activeCount > 0 && phase !== 'confirming' ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: activeCount > 0 && phase !== 'confirming' ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{phase === 'confirming' ? t('common.loading') : t('reservations.import.confirm', { count: activeCount })}
</button>
)}
</div>
</div>
</div>,
@@ -168,34 +168,6 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText('D2')).toBeInTheDocument()
})
// ── #1330: route tools for a single optimizable place ───────────────────────
it('FE-PLANNER-DAYPLAN-005b: route tools show for one located place with a bookend hotel (#1330)', () => {
const place = buildPlace({ name: 'Louvre', lat: 48.86, lng: 2.34 })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
const accommodations = [{ id: 1, start_day_id: 10, end_day_id: 11, place_lat: 48.85, place_lng: 2.35 }]
render(<DayPlanSidebar {...makeDefaultProps({
days: [day, day2], places: [place], assignments: { '10': [assignment] },
accommodations: accommodations as any, selectedDayId: 10,
})} />)
// With accommodation optimization on, one located place is routable (hotel → place → hotel),
// so the route tools (here the Google Maps export button) must be visible.
expect(screen.getByRole('button', { name: 'Open in Google Maps' })).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-005c: route tools stay hidden for one place with no bookend hotel (#1330 guard)', () => {
const place = buildPlace({ name: 'Louvre', lat: 48.86, lng: 2.34 })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] },
accommodations: [], selectedDayId: 10,
})} />)
// No accommodation to bookend the lone place, so nothing routable — tools stay hidden.
expect(screen.queryByRole('button', { name: 'Open in Google Maps' })).not.toBeInTheDocument()
})
// ── Day expansion/collapse ──────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-006: days are expanded by default', () => {
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
import { assignmentsApi, reservationsApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
@@ -35,7 +35,6 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
interface DayPlanSidebarProps {
tripId: number
@@ -155,9 +154,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({})
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
// Recompute the hotel/route legs when the user flips km↔mi so the connector
// distances refresh instead of showing stale cached text (#1300).
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const legsAbortRef = useRef<AbortController | null>(null)
const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set())
@@ -415,30 +411,25 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// waypoint of the day (morning) and from the last one back to it (evening). Only when
// the "optimize from accommodation" setting is on and the day has a hotel.
const day = days.find(d => d.id === selectedDayId)
const bookends = day && optimizeFromAccommodation !== false
? getDayBookendHotels(day, days, accommodations)
: null
const startHotel = bookends?.morning
const endHotel = bookends?.evening
const { morning: startHotel, evening: endHotel } =
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {}
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
// Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel
// legs connect even when the day starts or ends with a booking rather than a place. Track
// whether each is a place so we can skip a hotel↔transport leg that isn't real: on a day-1
// arrival the check-in hotel never drove to the departure airport (#1321).
const wayPts: { lat: number; lng: number; isPlace: boolean }[] = []
// legs connect even when the day starts or ends with a booking rather than a place.
const wayPts: { lat: number; lng: number }[] = []
for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true })
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
} else if (it.type === 'transport') {
const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId)
if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false })
if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false })
if (from) wayPts.push({ lat: from.lat, lng: from.lng })
if (to) wayPts.push({ lat: to.lat, lng: to.lng })
}
}
const firstWay = wayPts[0]
const lastWay = wayPts[wayPts.length - 1]
const wantTop = !!(startHotel && firstWay && (firstWay.isPlace || bookends?.morningIsSleptHere))
const wantBottom = !!(endHotel && lastWay && (lastWay.isPlace || bookends?.eveningIsOvernight))
const wantTop = !!(startHotel && firstWay)
const wantBottom = !!(endHotel && lastWay)
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
@@ -474,7 +465,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) }
})()
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit])
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation])
const openAddNote = (dayId, e) => {
e?.stopPropagation()
@@ -1055,9 +1046,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
const S = useDayPlanSidebar(props)
// Needed by the route-tools visibility gate in the render below (#1330); the hook
// keeps its own copy, so read it reactively here in the component scope too.
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
const {
tripId,
trip,
@@ -1243,16 +1231,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const cost = dayTotalCost(day.id, assignments, currency)
const formattedDate = formatDate(day.date, locale)
const loc = da.find(a => a.place?.lat && a.place?.lng)
// Route tools normally need 2+ stops, but a single located place is still
// routable when accommodation optimization can bookend it with a hotel
// (hotel → place → hotel, the same line the map draws) — otherwise the tools
// vanish on such a day (#1330). Purely additive to the 2+ case.
const routeBookends = optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : null
const hasRouteBookend = !!(
(routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null) ||
(routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null)
)
const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend)
const isDragTarget = dragOverDayId === day.id
const merged = mergedItemsMap[day.id] || []
const dayNoteUi = noteUi[day.id]
@@ -1617,17 +1595,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => {
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
ctxMenu.open(e, [
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
{ divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])
}}
onContextMenu={e => ctxMenu.open(e, [
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
{ divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
onMouseEnter={e => {
if (!isPlaceSelected && !lockedIds.has(assignment.id))
e.currentTarget.style.background = 'var(--bg-hover)'
@@ -2176,8 +2151,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)}
</div>
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte — oder 1 Ort mit Hotel-Bookend, #1330) */}
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button
@@ -2193,28 +2168,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<RouteIcon size={12} strokeWidth={2} />
{t('dayplan.route')}
</button>
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<button
onClick={() => {
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
title={t('planner.openGoogleMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<path d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
<path d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
<path d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
</svg>
</button>
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
@@ -2313,4 +2266,4 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)
})
export default DayPlanSidebar
export default DayPlanSidebar
@@ -13,7 +13,6 @@ export interface PlaceFormData {
// Populated from a maps-search pick (not part of the initial blank form).
phone?: string
google_place_id?: string
google_ftid?: string
osm_id?: string
}
@@ -399,38 +399,17 @@ describe('PlaceFormModal', () => {
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-026: time section is hidden in edit mode when no assignment is in context', () => {
// Times are per day-assignment; editing a pool place with no day in context
// (assignmentId null) hides the fields, which otherwise would not persist (#1247)
it('FE-PLANNER-PLACEFORM-026: time section IS shown in edit mode', () => {
const place = buildPlace({ name: 'Test' });
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-026b: time section IS shown when an assignment is in context', () => {
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '10:00' });
const assignment = buildAssignment({ id: 10, day_id: 5, place });
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
// Time pickers are rendered when editing
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
});
it('FE-PLANNER-PLACEFORM-026c: hydrates Start/End from the assignment when the pool place lacks times (#1247)', () => {
// The pool Place carries no times — they live on the day-assignment. Opening the
// editor with an assignmentId must hydrate the fields from assignment.place, not
// the (timeless) pool place that the Places panel passes in.
const poolPlace = buildPlace({ id: 7, name: 'Museum' });
const assignmentPlace = buildPlace({ id: 7, name: 'Museum', place_time: '20:20', end_time: '20:34' });
const assignment = buildAssignment({ id: 42, day_id: 3, place: assignmentPlace });
render(<PlaceFormModal {...defaultProps} place={poolPlace} assignmentId={42} dayAssignments={[assignment]} />);
expect(screen.getByDisplayValue('20:20')).toBeInTheDocument();
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
// Build an assignment whose place has end_time before place_time
// Build a place with end_time before place_time
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
const assignment = buildAssignment({ id: 11, day_id: 5, place });
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={11} dayAssignments={[assignment]} />);
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
// hasTimeError = true → submit button disabled
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
@@ -79,7 +79,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
const [pendingFiles, setPendingFiles] = useState([])
const fileRef = useRef(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -93,11 +92,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
useEffect(() => {
if (place) {
// Times are stored per day-assignment, not on the pool place. When an
// assignment is in context (itinerary edit, or a single-assignment pool
// edit) read the times off its embedded place; fall back to the place prop.
const assignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
const timeSource = assignment?.place ?? place
setForm({
name: place.name || '',
description: place.description || '',
@@ -105,8 +99,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lat: place.lat != null ? String(place.lat) : '',
lng: place.lng != null ? String(place.lng) : '',
category_id: place.category_id != null ? String(place.category_id) : '',
place_time: timeSource.place_time || '',
end_time: timeSource.end_time || '',
place_time: place.place_time || '',
end_time: place.end_time || '',
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
@@ -127,21 +121,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
setPendingFiles([])
setDuplicateWarning(null)
// dayAssignments is a fresh array each render; read it at open-time only and
// re-run on identity changes (place/assignmentId/open), not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [place, prefillCoords, isOpen, assignmentId])
useEffect(() => {
if (isOpen) {
setTimeout(() => {
const modal = searchInputRef.current?.closest('[role="dialog"]') ?? document.body
if (!modal.contains(document.activeElement) || document.activeElement === document.body) {
searchInputRef.current?.focus()
}
}, 50)
}
}, [isOpen])
}, [place, prefillCoords, isOpen])
// Derive location bias bounding box from the trip's existing places
const places = useTripStore((s) => s.places)
@@ -229,7 +209,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
address: resolved.address || prev.address,
lat: String(resolved.lat),
lng: String(resolved.lng),
google_ftid: resolved.google_ftid || prev.google_ftid,
}))
setMapsResults([])
setMapsSearch('')
@@ -254,7 +233,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lat: result.lat || prev.lat,
lng: result.lng || prev.lng,
google_place_id: result.google_place_id || prev.google_place_id,
google_ftid: result.google_ftid || prev.google_ftid,
osm_id: result.osm_id || prev.osm_id,
website: result.website || prev.website,
phone: result.phone || prev.phone,
@@ -448,7 +426,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
canUploadFiles,
places,
locationBias,
searchInputRef,
fetchSuggestions,
handleChange,
handleMapsSearch,
@@ -510,7 +487,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
canUploadFiles,
places,
locationBias,
searchInputRef,
fetchSuggestions,
handleChange,
handleMapsSearch,
@@ -562,7 +538,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
<div className="relative">
<div className="flex gap-2">
<input
ref={searchInputRef}
type="text"
value={mapsSearch}
onChange={e => setMapsSearch(e.target.value)}
@@ -753,11 +728,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
)}
</div>
{/* Time is per day-assignment: only shown when a single assignment is in
context (itinerary edit, or a single-assignment pool edit). Hidden when
creating, and for unassigned / multi-day pool edits where a single time
is ambiguous and wouldn't persist. */}
{place && assignmentId && (
{/* Time — only shown when editing, not when creating */}
{place && (
<TimeSection
form={form}
handleChange={handleChange}
@@ -618,22 +618,6 @@ describe('PlaceInspector', () => {
expect(mapsBtn).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-043b: Google Maps action uses google_ftid over coordinates', async () => {
const user = userEvent.setup();
const mapsUrl = "https://www.google.com/maps/place/?q=St.%20Jacobs%20Farmers'%20Market&ftid=0x882bf179e806d471:0x8591dde29c821a93";
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
render(<PlaceInspector {...defaultProps} place={buildPlace({
name: "St. Jacobs Farmers' Market",
lat: 43.5118527,
lng: -80.5542617,
google_ftid: '0x882bf179e806d471:0x8591dde29c821a93',
})} />);
const mapsBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Google Maps'))!;
await user.click(mapsBtn);
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank');
openSpy.mockRestore();
});
// ── No files section when no upload handler and no files ──────────────────
it('FE-PLANNER-INSPECTOR-044: files section hidden when no files and no onFileUpload', () => {
@@ -702,3 +686,4 @@ describe('PlaceInspector', () => {
});
});
@@ -12,8 +12,6 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
import { formatDistance, formatElevation } from '../../utils/units'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
const detailsCache = new Map()
@@ -124,7 +122,6 @@ export default function PlaceInspector({
const { t, locale, language } = useTranslation()
const toast = useToast()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
const [hoursExpanded, setHoursExpanded] = useState(false)
const [filesExpanded, setFilesExpanded] = useState(false)
const [isUploading, setIsUploading] = useState(false)
@@ -165,11 +162,6 @@ export default function PlaceInspector({
const openingHours = googleDetails?.opening_hours || null
const openNow = googleDetails?.open_now ?? null
// Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google.
const googleMapsUrl = getGoogleMapsUrlForPlace(
place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null,
googleDetails?.google_maps_url,
)
const selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
@@ -282,8 +274,7 @@ export default function PlaceInspector({
<PlaceExtras openingHours={openingHours} weekdayIndex={weekdayIndex} hoursExpanded={hoursExpanded}
setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles}
onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded}
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading}
distanceUnit={distanceUnit} />
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} />
</div>
@@ -297,10 +288,14 @@ export default function PlaceInspector({
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
)
)}
{googleMapsUrl && (
<ActionButton onClick={() => window.open(googleMapsUrl, '_blank')} variant="ghost" icon={<Navigation size={13} />}
{googleDetails?.google_maps_url && (
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
)}
{!googleDetails?.google_maps_url && place.lat && place.lng && (
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">Google Maps</span>} />
)}
{(place.website || googleDetails?.website) && (
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
@@ -687,7 +682,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
}
function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpanded, timeFormat, t, place,
placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading, distanceUnit }: any) {
placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading }: any) {
return (
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && (
@@ -780,20 +775,20 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" />
{formatDistance(distKm, distanceUnit)}
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
</div>
{hasEle && (
<>
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
<Mountain size={12} color="#22c55e" />
{formatElevation(maxEle, distanceUnit)}
{Math.round(maxEle)} m
</div>
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
<Mountain size={12} color="#ef4444" />
{formatElevation(minEle, distanceUnit)}
{Math.round(minEle)} m
</div>
<div className="text-content-muted" style={{ fontSize: 12 }}>
{formatElevation(totalUp, distanceUnit)} &nbsp;{formatElevation(totalDown, distanceUnit)}
{Math.round(totalUp)} m &nbsp;{Math.round(totalDown)} m
</div>
</>
)}
@@ -124,40 +124,6 @@ describe('PlacesSidebar', () => {
expect(screen.getByText('Central Park')).toBeInTheDocument();
});
it('FE-COMP-PLACES-009a: selected visible place is scrolled into view', async () => {
const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType<typeof vi.fn>;
scrollIntoView.mockClear();
const places = [
buildPlace({ id: 10, name: 'First Place' }),
buildPlace({ id: 42, name: 'Map Click Target' }),
];
render(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={42} />);
const selectedRow = screen.getByText('Map Click Target').closest('[data-place-id="42"]');
expect(selectedRow).toHaveAttribute('aria-selected', 'true');
await waitFor(() => {
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
});
});
it('FE-COMP-PLACES-009b: selected place hidden by search is not scrolled', async () => {
const user = userEvent.setup();
const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType<typeof vi.fn>;
const places = [
buildPlace({ id: 10, name: 'Visible Cafe' }),
buildPlace({ id: 42, name: 'Hidden Museum' }),
];
const { rerender } = render(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={null} />);
await user.type(screen.getByPlaceholderText(/Search places/i), 'Visible');
scrollIntoView.mockClear();
rerender(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={42} />);
expect(screen.queryByText('Hidden Museum')).not.toBeInTheDocument();
expect(scrollIntoView).not.toHaveBeenCalled();
});
it('FE-COMP-PLACES-010: shows place count', () => {
const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })];
render(<PlacesSidebar {...defaultProps} places={places} />);
@@ -5,7 +5,7 @@ export function PlacesList(S: SidebarState) {
const {
filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace,
categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId,
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
} = S
return (
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
@@ -44,7 +44,6 @@ export function PlacesList(S: SidebarState) {
onAssignToDay={onAssignToDay}
toggleSelected={toggleSelected}
setDayPickerPlace={setDayPickerPlace}
registerPlaceRow={registerPlaceRow}
/>
)
})
@@ -21,21 +21,17 @@ interface MemoPlaceRowProps {
onAssignToDay: (placeId: number, dayId?: number) => void
toggleSelected: (id: number) => void
setDayPickerPlace: (place: any) => void
registerPlaceRow: (placeId: number, element: HTMLDivElement | null) => void
}
export const MemoPlaceRow = React.memo(function MemoPlaceRow({
place, category: cat, isSelected, isPlanned, inDay, isChecked,
selectMode, selectedDayId, canEditPlaces, isMobile, t,
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
}: MemoPlaceRowProps) {
const hasGeometry = Boolean(place.route_geometry)
return (
<div
key={place.id}
ref={element => registerPlaceRow(place.id, element)}
aria-selected={isSelected}
data-place-id={place.id}
draggable={!selectMode}
onDragStart={e => {
e.dataTransfer.setData('placeId', String(place.id))
@@ -343,51 +343,56 @@ describe('ReservationModal', () => {
// ── Budget addon ─────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-024: costs section (create expense) visible when budget addon is enabled', () => {
it('FE-PLANNER-RESMODAL-024: budget section visible when budget addon is enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<ReservationModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-025: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
it('FE-PLANNER-RESMODAL-025: budget price input accepts valid decimal', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue({ id: 55 });
const onOpenExpense = vi.fn();
render(<ReservationModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
render(<ReservationModal {...defaultProps} />);
const priceInput = screen.getByPlaceholderText('0.00');
await userEvent.type(priceInput, '99.99');
expect((priceInput as HTMLInputElement).value).toBe('99.99');
});
it('FE-PLANNER-RESMODAL-026: budget hint shown when price > 0', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<ReservationModal {...defaultProps} />);
const priceInput = screen.getByPlaceholderText('0.00');
await userEvent.type(priceInput, '50');
expect(screen.getByText(/budget entry will be created/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-027: budget fields included in onSave when price is set', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await userEvent.type(screen.getByPlaceholderText('0.00'), '120');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
await waitFor(() =>
expect(onOpenExpense).toHaveBeenCalledWith(
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 55 }) })
)
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 120 }) })
);
});
it('FE-PLANNER-RESMODAL-026: linked expense summary shown for a booking with a linked cost', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: 'EUR', category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
],
});
render(<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />);
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
});
// ── File upload ───────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
@@ -594,6 +599,22 @@ describe('ReservationModal', () => {
expect(filePickerItem).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-044: budget category dropdown options include existing categories', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 1, trip_id: 1, name: 'Flight ticket', total_price: 300, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
],
});
render(<ReservationModal {...defaultProps} />);
// Budget section is visible
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
@@ -611,6 +632,31 @@ describe('ReservationModal', () => {
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
});
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 1, trip_id: 1, name: 'Ticket', total_price: 100, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
],
});
render(<ReservationModal {...defaultProps} />);
// Open the budget category CustomSelect (shows placeholder "Auto (from booking type)")
const budgetCategoryBtn = screen.getByText(/Auto \(from booking type\)/i).closest('button')!;
await userEvent.click(budgetCategoryBtn);
// Click the "Transport" category option
await waitFor(() => expect(screen.getByText('Transport')).toBeInTheDocument());
await userEvent.click(screen.getByText('Transport'));
// The select should now show "Transport"
expect(screen.getByText('Transport')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
render(<ReservationModal {...defaultProps} />);
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
@@ -11,12 +11,7 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { openFile } from '../../utils/fileDownload'
import { resolveDayId } from '../../utils/formatters'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import { typeToCostCategory } from '@trek/shared'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
const TYPE_OPTIONS = [
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
@@ -65,13 +60,9 @@ interface ReservationModalProps {
onFileDelete: (fileId: number) => Promise<void>
accommodations?: Accommodation[]
defaultAssignmentId?: number | null
onOpenExpense?: (req: BookingExpenseRequest) => void
// Pre-fill a brand-new booking from a parsed import item (review-before-save).
// Distinct from `reservation`: the form is populated but stays in create mode.
prefill?: BookingReviewDraft | null
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense, prefill = null }: ReservationModalProps) {
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
const { id: tripId } = useParams<{ id: string }>()
const loadFiles = useTripStore(s => s.loadFiles)
const toast = useToast()
@@ -79,21 +70,24 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const fileInputRef = useRef(null)
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
// Set right before submit when the user clicked create/edit expense (see TransportModal).
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
price: '', budget_category: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
hotel_address: '',
})
const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingFiles, setPendingFiles] = useState([])
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
@@ -103,16 +97,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
)
useEffect(() => {
// Match an existing place by name (exact, then loose contains) for hotels.
const matchPlaceId = (name: string | undefined): string | number => {
const n = (name || '').trim().toLowerCase()
if (!n) return ''
const exact = places.find(p => p.name?.trim().toLowerCase() === n)
if (exact) return exact.id
const loose = places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
return loose?.id ?? ''
}
if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
const rawEnd = reservation.reservation_end_time || ''
@@ -125,7 +109,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
endDate = rawEnd
endTime = ''
}
const editAcc = accommodations.find(a => a.id == reservation.accommodation_id)
setForm({
title: reservation.title || '',
type: reservation.type || 'other',
@@ -141,53 +124,24 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
hotel_place_id: editAcc?.place_id || '',
hotel_start_day: editAcc?.start_day_id || '',
hotel_end_day: editAcc?.end_day_id || '',
hotel_address: places.find(p => p.id == editAcc?.place_id)?.address || '',
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
} else if (prefill) {
// Review-before-save: populate from a parsed import item, stay in create mode.
const meta = (prefill.metadata && typeof prefill.metadata === 'object' ? prefill.metadata : {}) as Record<string, string>
const rawEnd = typeof prefill.reservation_end_time === 'string' ? prefill.reservation_end_time : ''
let endDate = ''
let endTime = rawEnd
if (rawEnd.includes('T')) { endDate = rawEnd.split('T')[0]; endTime = rawEnd.split('T')[1]?.slice(0, 5) || '' }
else if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) { endDate = rawEnd; endTime = '' }
setForm({
title: prefill.title || '',
type: prefill.type || 'other',
status: prefill.status || 'pending',
reservation_time: typeof prefill.reservation_time === 'string' ? prefill.reservation_time.slice(0, 16) : '',
reservation_end_time: endTime,
end_date: endDate,
location: prefill.location || '',
confirmation_number: prefill.confirmation_number || '',
notes: prefill.notes || '',
assignment_id: defaultAssignmentId ?? '',
accommodation_id: '',
meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
hotel_place_id: matchPlaceId(prefill._venue?.name || prefill.title),
hotel_start_day: resolveDayId(days, prefill._accommodation?.check_in),
hotel_end_day: resolveDayId(days, prefill._accommodation?.check_out),
hotel_address: prefill._venue?.address || '',
})
// Seed the booking's Files with the document this item was parsed from.
setPendingFiles(prefill._sourceFiles ?? [])
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
price: '', budget_category: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
})
setPendingFiles([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId, days, places, accommodations])
}, [reservation, isOpen, selectedDayId, defaultAssignmentId])
// Re-hydrate hotel day range when the accommodations prop arrives after the modal opens
// (race: tripAccommodations fetch may complete after isOpen fires, leaving hotel fields empty)
@@ -213,8 +167,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
return endFull <= startFull
})()
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
e?.preventDefault?.()
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.title.trim()) return
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
setIsSaving(true)
@@ -231,6 +185,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
} else if (form.reservation_end_time && form.reservation_time) {
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const saveData: Record<string, any> & { title: string } = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
@@ -243,33 +202,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
endpoints: [],
needs_review: false,
}
if (form.type === 'hotel' && (form.hotel_start_day || form.hotel_end_day)) {
if (isBudgetEnabled) {
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = {
place_id: form.hotel_place_id || null,
// No existing place picked but we have an address/name (e.g. a reviewed
// import) → the save handler geocodes it and creates the place.
venue: (!form.hotel_place_id && (form.hotel_address || form.title))
? { name: form.title, address: form.hotel_address || null }
: null,
// Tolerate a single resolved end of the range (a one-night stay or a date
// that only matched one trip day) so the accommodation is still created.
start_day_id: form.hotel_start_day || form.hotel_end_day,
end_day_id: form.hotel_end_day || form.hotel_start_day,
start_day_id: form.hotel_start_day,
end_day_id: form.hotel_end_day,
check_in: form.meta_check_in_time || null,
check_in_end: form.meta_check_in_end_time || null,
check_out: form.meta_check_out_time || null,
confirmation: form.confirmation_number || null,
}
}
// Imported booking → auto-create the linked cost from the parsed price (what the
// old direct import did). Only on create (not edit) and only when there's a price.
if (!reservation && prefill && isBudgetEnabled) {
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
const price = Number(pmeta.price)
if (Number.isFinite(price) && price > 0) {
saveData.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
}
}
const saved = await onSave(saveData)
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
for (const file of pendingFiles) {
@@ -280,32 +228,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
await onFileUpload(fd)
}
}
// Open the Costs editor for the saved booking when the user asked to
// create/edit its linked expense (gated on saved?.id).
const intent = expenseIntentRef.current
expenseIntentRef.current = null
if (intent && onOpenExpense && saved?.id) {
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
}
} finally {
setIsSaving(false)
}
}
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
const handleRemoveExpense = async (item: BudgetItem) => {
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
}
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
const prefillPrice = Number(prefillMeta?.price)
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
: null
const handleFileChange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
@@ -569,11 +496,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
/>
</div>
</div>
<div>
<label className={labelClass}>{t('reservations.locationAddress')}</label>
<input type="text" value={form.hotel_address} onChange={e => set('hotel_address', e.target.value)}
placeholder={t('reservations.locationPlaceholder')} className={inputClass} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.checkIn')}</label>
@@ -688,15 +610,38 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Costs — create / view the expense linked to this booking */}
{/* Price + Budget Category */}
{isBudgetEnabled && (
<BookingCostsSection
reservationId={reservation?.id ?? null}
pendingExpense={pendingExpense}
onCreate={handleCreateExpense}
onEdit={handleEditExpense}
onRemove={handleRemoveExpense}
/>
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
className={inputClass} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
</form>
@@ -312,8 +312,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
if (!hasEndpoints && meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat + (meta.class ? ` · ${meta.class}` : '') })
if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: `${meta.price}${meta.priceCurrency ? ' ' + meta.priceCurrency : ''}` })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
if (cells.length === 0) return null
@@ -132,37 +132,34 @@ describe('TransportModal', () => {
// ── Budget addon ─────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-011: costs section (create expense) visible when budget addon is enabled', () => {
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<TransportModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-012: costs section not shown when budget addon is disabled', () => {
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-013: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue({ id: 42 });
const onOpenExpense = vi.fn();
render(<TransportModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
// The legacy auto-budget mechanism is gone; the expense is created via the editor instead.
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
await waitFor(() =>
expect(onOpenExpense).toHaveBeenCalledWith(
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 42 }) })
)
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
);
});
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal'
@@ -10,15 +10,11 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters'
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import { typeToCostCategory } from '@trek/shared'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
@@ -109,6 +105,8 @@ const defaultForm = {
arrival_time: '',
confirmation_number: '',
notes: '',
price: '',
budget_category: '',
meta_airline: '',
meta_flight_number: '',
meta_train_number: '',
@@ -126,23 +124,20 @@ interface TransportModalProps {
files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<unknown>
onFileDelete?: (fileId: number) => Promise<void>
onOpenExpense?: (req: BookingExpenseRequest) => void
// Pre-fill a brand-new transport booking from a parsed import item (review-
// before-save); like `reservation` for the form but stays in create mode.
prefill?: BookingReviewDraft | null
}
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null }: TransportModalProps) {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
const loadFiles = useTripStore(s => s.loadFiles)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const { id: tripId } = useParams<{ id: string }>()
// Set right before submitting when the user clicked "create/edit expense", so
// the post-save handler knows to open the Costs editor for the saved booking.
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
@@ -157,42 +152,36 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
useEffect(() => {
if (!isOpen) return
// Edit uses the saved `reservation`; a review-import populates from `prefill`.
// Either way the init reads the same fields — `reservation` still decides
// edit-vs-create at submit time.
const src = (reservation ?? prefill) as Reservation | null
// On a review-import, seed the booking's Files with the parsed source document.
setPendingFiles(!reservation && prefill?._sourceFiles ? prefill._sourceFiles : [])
if (src) {
const meta = typeof src.metadata === 'string'
? JSON.parse(src.metadata || '{}')
: (src.metadata || {})
const eps = src.endpoints || []
if (reservation) {
const meta = typeof reservation.metadata === 'string'
? JSON.parse(reservation.metadata || '{}')
: (reservation.metadata || {})
const eps = reservation.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
const type = (TRANSPORT_TYPES as readonly string[]).includes(src.type)
? src.type as TransportType
const type = (TRANSPORT_TYPES as readonly string[]).includes(reservation.type)
? reservation.type as TransportType
: 'flight'
setForm({
title: src.title || '',
title: reservation.title || '',
type,
status: src.status === 'confirmed' ? 'confirmed' : 'pending',
// For an edit, keep the saved day; for an imported prefill (no day_id), resolve it
// from the parsed pick-up/return date so the date isn't lost on save.
start_day_id: src.day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_time).date),
end_day_id: src.end_day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_end_time).date),
departure_time: splitReservationDateTime(src.reservation_time).time ?? '',
arrival_time: splitReservationDateTime(src.reservation_end_time).time ?? '',
confirmation_number: src.confirmation_number || '',
notes: src.notes || '',
status: reservation.status === 'confirmed' ? 'confirmed' : 'pending',
start_day_id: reservation.day_id ?? '',
end_day_id: reservation.end_day_id ?? '',
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
meta_airline: meta.airline || '',
meta_flight_number: meta.flight_number || '',
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
if (type === 'flight') {
const orderedEps = orderedEndpoints(src)
const orderedEps = orderedEndpoints(reservation)
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
let wps: WaypointForm[]
if (orderedEps.length >= 2) {
@@ -203,9 +192,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const isLast = i === orderedEps.length - 1
return {
airport: airportFromEndpoint(ep),
arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''),
arrDayId: legInto?.arr_day_id ?? (isLast ? (reservation.end_day_id ?? '') : ''),
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (isFirst ? (src.day_id ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (isFirst ? (reservation.day_id ?? '') : ''),
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
@@ -214,15 +203,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
})
} else {
// Legacy flight with no (or partial) endpoints — seed two waypoints.
const dep = emptyWaypoint(src.day_id ?? '')
const dep = emptyWaypoint(reservation.day_id ?? '')
dep.airport = airportFromEndpoint(from)
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
dep.airline = meta.airline ?? ''
dep.flight_number = meta.flight_number ?? ''
dep.seat = meta.seat ?? ''
const arr = emptyWaypoint(src.end_day_id ?? src.day_id ?? '')
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
arr.airport = airportFromEndpoint(to)
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
wps = [dep, arr]
}
setWaypoints(wps)
@@ -236,12 +225,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setToPick({})
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
}
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
}, [isOpen, reservation, selectedDayId, budgetItems])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.title.trim()) return
setIsSaving(true)
try {
@@ -300,6 +289,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
@@ -340,14 +334,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints,
needs_review: false,
}
// Imported booking → auto-create the linked cost from the parsed price (what the
// old direct import did). Only on create (not edit) and only when there's a price.
if (!reservation && prefill && isBudgetEnabled) {
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
const price = Number(pmeta.price)
if (Number.isFinite(price) && price > 0) {
;(payload as Record<string, unknown>).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
}
if (isBudgetEnabled) {
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
const saved = await onSave(payload)
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
@@ -359,14 +349,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
await onFileUpload(fd)
}
}
// The user asked to create/edit the linked expense — open the Costs editor
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
const intent = expenseIntentRef.current
expenseIntentRef.current = null
if (intent && onOpenExpense && saved?.id) {
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
}
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} finally {
@@ -374,19 +356,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
}
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
const handleRemoveExpense = async (item: BudgetItem) => {
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
}
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
const prefillPrice = Number(prefillMeta?.price)
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
: null
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
@@ -743,15 +712,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</div>
</div>
{/* Costs — create / view the expense linked to this booking */}
{/* Price + Budget Category */}
{isBudgetEnabled && (
<BookingCostsSection
reservationId={reservation?.id ?? null}
pendingExpense={pendingExpense}
onCreate={handleCreateExpense}
onEdit={handleEditExpense}
onRemove={handleRemoveExpense}
/>
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
className={inputClass} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
</form>
@@ -1,49 +0,0 @@
import type { BookingImportPreviewItem, Reservation, ReservationEndpoint } from '@trek/shared'
/**
* A pre-fill draft for the reservation/transport edit modals built from a parsed
* booking-import item. Carries the normal reservation fields the modals read for
* their form, plus the import-only `_venue`/`_accommodation` the hotel path needs
* to suggest a place and a day range. It has no `id` the modal stays in
* "create" mode and the user reviews/edits before it is ever persisted.
*/
export interface BookingReviewDraft extends Omit<Partial<Reservation>, 'metadata' | 'endpoints'> {
/** Type-specific extras (airline, flight_number, check_in_time, price, …) as an object. */
metadata?: Record<string, unknown> | null
endpoints?: ReservationEndpoint[]
/** Parsed venue (auto-created place candidate) — hotel/restaurant/event. */
_venue?: BookingImportPreviewItem['_venue']
/** Parsed check-in/out + confirmation — hotels only. */
_accommodation?: BookingImportPreviewItem['_accommodation']
/** The uploaded source file(s) the item was parsed from — attached to the booking on save. */
_sourceFiles?: File[]
}
/**
* Map a parsed booking item onto the shape the edit modals pre-fill from. Pure
* (no I/O). Transport items keep their geocoded endpoints; venue/accommodation
* ride along untouched so the hotel modal can match a place by name (or create
* one from the reviewed address on save).
*/
export function parsedItemToDraft(item: BookingImportPreviewItem): BookingReviewDraft {
return {
type: item.type,
title: item.title,
status: 'pending',
reservation_time: item.reservation_time ?? null,
reservation_end_time: item.reservation_end_time ?? null,
location: item.location ?? item._venue?.address ?? item._venue?.name ?? null,
confirmation_number: item.confirmation_number ?? null,
notes: null,
metadata: (item.metadata as Record<string, unknown> | undefined) ?? null,
endpoints: (item.endpoints ?? []) as ReservationEndpoint[],
_venue: item._venue,
_accommodation: item._accommodation,
}
}
/** Transport types route to the TransportModal; everything else to the ReservationModal. */
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
export function isTransportItem(item: BookingImportPreviewItem): boolean {
return TRANSPORT_TYPES.has(item.type)
}
@@ -1,36 +0,0 @@
import { describe, it, expect } from 'vitest'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
const base = { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, google_place_id: null, google_ftid: null } as any
describe('getGoogleMapsUrlForPlace', () => {
it('FE-PLACE-GMAPS-001: uses a valid ftid for a precise /place link', () => {
const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0x47e66e2964e34e2d:0x8ddca9ee380ef7e0' })
expect(url).toBe('https://www.google.com/maps/place/?q=Eiffel%20Tower&ftid=0x47e66e2964e34e2d:0x8ddca9ee380ef7e0')
})
it('FE-PLACE-GMAPS-002: falls back to query_place_id when there is no ftid', () => {
const url = getGoogleMapsUrlForPlace({ ...base, google_place_id: 'ChIJ123' })
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123')
})
it('FE-PLACE-GMAPS-003: ignores a malformed/hostile ftid and falls through to the place id', () => {
const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0xAB&q=evil', google_place_id: 'ChIJ123' })
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123')
})
it('FE-PLACE-GMAPS-004: uses the details URL when there is no ftid or place id', () => {
const url = getGoogleMapsUrlForPlace(base, 'https://maps.google.com/?cid=123')
expect(url).toBe('https://maps.google.com/?cid=123')
})
it('FE-PLACE-GMAPS-005: falls back to coordinates as a last resort', () => {
const url = getGoogleMapsUrlForPlace(base)
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=48.8584,2.2945')
})
it('FE-PLACE-GMAPS-006: returns null for no place or no location', () => {
expect(getGoogleMapsUrlForPlace(null)).toBeNull()
expect(getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null })).toBeNull()
})
})
@@ -1,19 +0,0 @@
import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i
export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, detailsUrl?: string | null): string | null {
if (!place) return null
const ftid = place.google_ftid?.trim()
if (ftid && GOOGLE_FTID_RE.test(ftid)) {
return `https://www.google.com/maps/place/?q=${encodeURIComponent(place.name)}&ftid=${ftid}`
}
const placeId = place.google_place_id?.trim()
if (placeId) {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.name)}&query_place_id=${encodeURIComponent(placeId)}`
}
if (detailsUrl) return detailsUrl
if (place.lat == null || place.lng == null) return null
return `https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`
}
@@ -9,7 +9,6 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
export interface PlacesSidebarProps {
tripId: number
@@ -60,8 +59,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [sidebarDragOver, setSidebarDragOver] = useState(false)
const sidebarDragCounter = useRef(0)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const placeRowRefs = useRef(new Map<number, HTMLDivElement>())
const lastAutoScrolledPlaceIdRef = useRef<number | null>(null)
useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop
@@ -200,28 +197,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
return true
}), [places, filter, categoryFilters, search, plannedIds])
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
if (element) {
placeRowRefs.current.set(placeId, element)
} else {
placeRowRefs.current.delete(placeId)
}
}, [])
useEffect(() => {
if (!props.selectedPlaceId) {
lastAutoScrolledPlaceIdRef.current = null
return
}
if (lastAutoScrolledPlaceIdRef.current === props.selectedPlaceId) return
if (!filtered.some(place => place.id === props.selectedPlaceId)) return
const selectedRow = placeRowRefs.current.get(props.selectedPlaceId)
if (!selectedRow) return
selectedRow.scrollIntoView({ behavior: 'smooth', block: 'center' })
lastAutoScrolledPlaceIdRef.current = props.selectedPlaceId
}, [filtered, props.selectedPlaceId])
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
@@ -235,12 +210,11 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
const selDayId = selectedDayIdRef.current
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
ctxMenu.open(e, [
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') },
{ divider: true },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) },
])
@@ -260,7 +234,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
hasTracks, plannedIds, filtered, isAssignedToSelectedDay, inDaySet, openContextMenu,
}
}
@@ -19,7 +19,6 @@ export default function AirTrailConnectionSection(): React.ReactElement {
const [url, setUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
const [writeEnabled, setWriteEnabled] = useState(false)
const [connected, setConnected] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -31,7 +30,6 @@ export default function AirTrailConnectionSection(): React.ReactElement {
.then(d => {
setUrl(d.url || '')
setAllowInsecureTls(!!d.allowInsecureTls)
setWriteEnabled(!!d.writeEnabled)
setConnected(!!d.connected)
})
.catch(() => {})
@@ -48,7 +46,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
const handleSave = async () => {
setSaving(true)
try {
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, writeEnabled, ...keyPayload() })
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, ...keyPayload() })
const status = await airtrailApi.status().catch(() => ({ connected: false }))
setConnected(!!status.connected)
setApiKey('')
@@ -109,14 +107,6 @@ export default function AirTrailConnectionSection(): React.ReactElement {
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
</div>
<div>
<div className="flex items-center gap-3">
<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} />
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.writeBack')}</span>
</div>
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.writeBackHint')}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={handleSave}
@@ -150,22 +150,6 @@ describe('DisplaySettingsTab', () => {
expect(updateSetting).toHaveBeenCalledWith('temperature_unit', 'fahrenheit');
});
it('FE-COMP-DISPLAY-028: metric distance button is active by default', () => {
seedStore(useSettingsStore, { settings: { temperature_unit: 'celsius' } });
render(<DisplaySettingsTab />);
const metricBtn = screen.getByText('km Metric').closest('button')!;
expect(metricBtn.style.border).toContain('var(--text-primary)');
});
it('FE-COMP-DISPLAY-029: clicking imperial distance calls updateSetting with imperial', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }), updateSetting });
render(<DisplaySettingsTab />);
await user.click(screen.getByText('mi Imperial'));
expect(updateSetting).toHaveBeenCalledWith('distance_unit', 'imperial');
});
it('FE-COMP-DISPLAY-020: clicking 24h time format calls updateSetting with 24h', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
@@ -6,14 +6,12 @@ import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
import Section from './Section'
import type { DistanceUnit } from '../../types'
export default function DisplaySettingsTab(): React.ReactElement {
const { settings, updateSetting } = useSettingsStore()
const { t } = useTranslation()
const toast = useToast()
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || 'metric')
const [langOpen, setLangOpen] = useState(false)
const langDropdownRef = useRef<HTMLDivElement | null>(null)
@@ -30,10 +28,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
setTempUnit(settings.temperature_unit || 'celsius')
}, [settings.temperature_unit])
useEffect(() => {
setDistanceUnit(settings.distance_unit || 'metric')
}, [settings.distance_unit])
return (
<Section title={t('settings.display')} icon={Palette}>
{/* Display currency */}
@@ -206,37 +200,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div>
</div>
{/* Distance */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.distance')}</label>
<div className="flex gap-3">
{([
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
] as const).map(opt => (
<button
key={opt.value}
onClick={async () => {
setDistanceUnit(opt.value)
try { await updateSetting('distance_unit', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: distanceUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: distanceUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Time Format */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.timeFormat')}</label>
@@ -7,7 +7,6 @@ import { authApi, oauthApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection'
import AirTrailConnectionSection from './AirTrailConnectionSection'
import LlmConnectionSection from './LlmConnectionSection'
import { ALL_SCOPES } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
@@ -100,7 +99,6 @@ export default function IntegrationsTab(): React.ReactElement {
<>
<PhotoProvidersSection />
{S.airtrailEnabled && <AirTrailConnectionSection />}
{S.llmEnabled && <LlmConnectionSection />}
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
<McpTokenModals {...S} />
<OAuthClientModals {...S} />
@@ -114,7 +112,6 @@ function useIntegrations() {
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
const llmEnabled = addonEnabled('llm_parsing')
useEffect(() => {
loadAddons()
@@ -295,7 +292,7 @@ function useIntegrations() {
return {
t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
t, locale, toast, mcpEnabled, airtrailEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
}
}
@@ -1,149 +0,0 @@
import React, { useEffect, useState } from 'react'
import { Sparkles, Save } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useSettingsStore } from '../../store/settingsStore'
import type { Settings } from '../../types'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import CustomSelect from '../shared/CustomSelect'
type Provider = NonNullable<Settings['llm_provider']>
/**
* Settings Integrations AI parsing. Per-user model used to extract bookings
* from uploaded files. It only takes effect when the admin has not configured an
* instance-wide model on the addon the server resolves the admin config first.
* The API key is stored encrypted and never prefilled: a blank field keeps the
* stored key (mirrors the AirTrail connection layout).
*/
export default function LlmConnectionSection(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const settings = useSettingsStore(s => s.settings)
const isLoaded = useSettingsStore(s => s.isLoaded)
const updateSettings = useSettingsStore(s => s.updateSettings)
const [provider, setProvider] = useState<Provider>('local')
const [model, setModel] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [multimodal, setMultimodal] = useState(false)
const [hasStoredKey, setHasStoredKey] = useState(false)
const [saving, setSaving] = useState(false)
// Hydrate from the loaded settings. llm_api_key arrives masked, so we only use
// its presence to drive the placeholder — never the value itself.
useEffect(() => {
if (!isLoaded) return
setProvider(settings.llm_provider || 'local')
setModel(settings.llm_model || '')
setBaseUrl(settings.llm_base_url || '')
setMultimodal(settings.llm_multimodal === true)
setHasStoredKey(!!settings.llm_api_key)
}, [isLoaded, settings.llm_provider, settings.llm_model, settings.llm_base_url, settings.llm_multimodal, settings.llm_api_key])
const needsKey = provider !== 'local'
const showBaseUrl = provider === 'local' || provider === 'openai'
const handleSave = async () => {
setSaving(true)
try {
const payload: Partial<Settings> = {
llm_provider: provider,
llm_model: model.trim(),
llm_base_url: showBaseUrl ? baseUrl.trim() : '',
llm_multimodal: multimodal,
}
// Send the key only when the user typed a new one — a blank field means
// "keep the stored key".
const key = apiKey.trim()
if (key) payload.llm_api_key = key
await updateSettings(payload)
setApiKey('')
if (key) setHasStoredKey(true)
toast.success(t('settings.aiParsing.toast.saved'))
} catch {
toast.error(t('settings.aiParsing.toast.saveError'))
} finally {
setSaving(false)
}
}
return (
<Section title={t('settings.aiParsing.title')} icon={Sparkles}>
<div className="space-y-3">
<p className="text-xs text-content-secondary">{t('settings.aiParsing.hint')}</p>
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.provider')}</label>
<CustomSelect
value={provider}
onChange={v => setProvider(v as Provider)}
options={[
{ value: 'local', label: t('settings.aiParsing.providerLocal') },
{ value: 'openai', label: t('settings.aiParsing.providerOpenai') },
{ value: 'anthropic', label: t('settings.aiParsing.providerAnthropic') },
]}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.model')}</label>
<input
type="text"
value={model}
onChange={e => setModel(e.target.value)}
placeholder="qwen3:8b"
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
/>
</div>
{showBaseUrl && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.baseUrl')}</label>
<input
type="url"
value={baseUrl}
onChange={e => setBaseUrl(e.target.value)}
placeholder="http://localhost:11434"
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
/>
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.baseUrlHint')}</p>
</div>
)}
{needsKey && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.apiKey')}</label>
<input
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
autoComplete="off"
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
/>
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.apiKeyHint')}</p>
</div>
)}
<div>
<div className="flex items-center gap-3">
<ToggleSwitch on={multimodal} onToggle={() => setMultimodal(v => !v)} />
<span className="text-sm font-medium text-content-secondary">{t('settings.aiParsing.multimodal')}</span>
</div>
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.multimodalHint')}</p>
</div>
<button
onClick={handleSave}
disabled={saving || !isLoaded}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50"
>
<Save className="w-4 h-4" /> {t('common.save')}
</button>
</div>
</Section>
)
}
@@ -1,22 +1,14 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Map, Save, Layers, Box, ChevronDown, Check, Globe2 } from 'lucide-react'
import { Map, Save, Layers, Box, ChevronDown, Check } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import GlMapPreview from './MapboxPreview'
import MapboxPreview from './MapboxPreview'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import type { Place } from '../../types'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
getStylePresets,
isOpenFreeMapStyle,
normalizeStyleForProvider,
type GlMapProvider,
} from '../Map/glProviders'
interface MapPreset {
name: string
@@ -31,6 +23,25 @@ const MAP_PRESETS: MapPreset[] = [
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
interface StylePreset {
name: string
url: string
tags: string[]
}
const MAPBOX_STYLE_PRESETS: StylePreset[] = [
{ name: 'Mapbox Standard', url: 'mapbox://styles/mapbox/standard', tags: ['3D', 'Apple-like'] },
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
]
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a
// user scanning the list can spot 3D / Satellite / Apple-like styles.
const TAG_STYLES: Record<string, string> = {
@@ -48,7 +59,6 @@ const TAG_STYLES: Record<string, string> = {
'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300',
'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
'OpenFreeMap': 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
}
function TagChip({ tag }: { tag: string }) {
@@ -60,11 +70,10 @@ function TagChip({ tag }: { tag: string }) {
)
}
function StyleDropdown({ value, provider, onChange }: { value: string; provider: GlMapProvider; onChange: (v: string) => void }) {
function StyleDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const presets = getStylePresets(provider)
useEffect(() => {
if (!open) return
@@ -75,10 +84,7 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
return () => document.removeEventListener('mousedown', onDoc)
}, [open])
const selected = presets.find(p => p.url === value)
const placeholder = provider === 'maplibre-gl'
? t('settings.mapOpenFreeMapStylePlaceholder')
: t('settings.mapStylePlaceholder')
const selected = MAPBOX_STYLE_PRESETS.find(p => p.url === value)
return (
<div ref={ref} className="relative">
@@ -89,11 +95,11 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
>
<span className="flex items-center gap-2 min-w-0">
<span className="text-slate-900 dark:text-white truncate">
{selected ? selected.name : placeholder}
{selected ? selected.name : t('settings.mapStylePlaceholder')}
</span>
{selected && (
<span className="flex items-center gap-1 flex-shrink-0">
{(selected.tags || []).map(t => <TagChip key={t} tag={t} />)}
{selected.tags.map(t => <TagChip key={t} tag={t} />)}
</span>
)}
</span>
@@ -101,7 +107,7 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
</button>
{open && (
<div className="absolute z-20 mt-1 w-full max-h-80 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg py-1">
{presets.map(preset => {
{MAPBOX_STYLE_PRESETS.map(preset => {
const isActive = preset.url === value
return (
<button
@@ -112,7 +118,7 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
>
<span className="flex items-center gap-2 flex-wrap">
<span className="text-slate-900 dark:text-white font-medium">{preset.name}</span>
{(preset.tags || []).map(t => <TagChip key={t} tag={t} />)}
{preset.tags.map(t => <TagChip key={t} tag={t} />)}
</span>
{isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />}
</button>
@@ -124,34 +130,17 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
)
}
type Provider = 'leaflet' | GlMapProvider
function normalizeProvider(value: unknown): Provider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
}
function styleForProvider(provider: Provider, style?: string | null): string {
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style)
}
// Each GL provider has its own style slot, so toggling providers never clobbers the
// other one's style. Leaflet/Mapbox use mapbox_style; MapLibre uses maplibre_style.
function slotStyle(provider: Provider, s: { mapbox_style?: string; maplibre_style?: string }): string | undefined {
return provider === 'maplibre-gl' ? s.maplibre_style : s.mapbox_style
}
type Provider = 'leaflet' | 'mapbox-gl'
export default function MapSettingsTab(): React.ReactElement {
const { settings, updateSettings } = useSettingsStore()
const { t } = useTranslation()
const toast = useToast()
const initialProvider = normalizeProvider(settings.map_provider)
const [saving, setSaving] = useState(false)
const [provider, setProvider] = useState<Provider>(initialProvider)
const [provider, setProvider] = useState<Provider>((settings.map_provider as Provider) || 'leaflet')
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
const [mapboxStyle, setMapboxStyle] = useState<string>(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
@@ -159,11 +148,10 @@ export default function MapSettingsTab(): React.ReactElement {
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
useEffect(() => {
const nextProvider = normalizeProvider(settings.map_provider)
setProvider(nextProvider)
setProvider((settings.map_provider as Provider) || 'leaflet')
setMapTileUrl(settings.map_tile_url || '')
setMapboxToken(settings.mapbox_access_token || '')
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
setMapboxStyle(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
setMapbox3d(settings.mapbox_3d_enabled !== false)
setMapboxQuality(settings.mapbox_quality_mode === true)
setDefaultLat(settings.default_lat || 48.8566)
@@ -198,15 +186,11 @@ export default function MapSettingsTab(): React.ReactElement {
const saveMapSettings = async (): Promise<void> => {
setSaving(true)
try {
const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle)
setMapboxStyle(glStyle)
// Save into the active provider's own slot so the other provider's style survives.
const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : { mapbox_style: glStyle }
await updateSettings({
map_provider: provider,
map_tile_url: mapTileUrl,
mapbox_access_token: mapboxToken,
...stylePatch,
mapbox_style: mapboxStyle,
mapbox_3d_enabled: mapbox3d,
mapbox_quality_mode: mapboxQuality,
default_lat: parseFloat(String(defaultLat)),
@@ -224,20 +208,16 @@ export default function MapSettingsTab(): React.ReactElement {
// 3D is available on every style now — pure satellite uses the
// mapbox-streets-v8 tileset as a fallback building source.
const supports3d = true
const changeProvider = (nextProvider: Provider) => {
setProvider(nextProvider)
if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
}
return (
<Section title={t('settings.map')} icon={Map}>
{/* Provider picker — big cards so the choice is obvious */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => changeProvider('leaflet')}
onClick={() => setProvider('leaflet')}
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'leaflet'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
@@ -252,7 +232,7 @@ export default function MapSettingsTab(): React.ReactElement {
</button>
<button
type="button"
onClick={() => changeProvider('mapbox-gl')}
onClick={() => setProvider('mapbox-gl')}
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'mapbox-gl'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
@@ -272,24 +252,6 @@ export default function MapSettingsTab(): React.ReactElement {
{t('settings.mapExperimental')}
</span>
</button>
<button
type="button"
onClick={() => changeProvider('maplibre-gl')}
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'maplibre-gl'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
}`}
>
<Globe2 size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-white">
<span className="sm:hidden">MapLibre</span>
<span className="hidden sm:inline">MapLibre GL</span>
</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapLibreSubtitle')}</div>
</div>
</button>
</div>
<p className="text-xs text-slate-400 mt-2">
{t('settings.mapProviderHint')}
@@ -319,10 +281,9 @@ export default function MapSettingsTab(): React.ReactElement {
</div>
)}
{/* GL settings */}
{provider !== 'leaflet' && (
{/* Mapbox GL settings */}
{provider === 'mapbox-gl' && (
<div className="space-y-3">
{provider === 'mapbox-gl' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
<input
@@ -339,27 +300,24 @@ export default function MapSettingsTab(): React.ReactElement {
</a>
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label>
<div className="mb-2">
<StyleDropdown value={mapboxStyle} provider={provider} onChange={setMapboxStyle} />
<StyleDropdown value={mapboxStyle} onChange={setMapboxStyle} />
</div>
<input
type="text"
value={mapboxStyle}
onChange={(e) => setMapboxStyle(e.target.value)}
placeholder={defaultStyleForProvider(provider)}
placeholder="mapbox://styles/mapbox/standard"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs text-slate-400 mt-1">
{provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}
{t('settings.mapStyleHint')}
</p>
</div>
{provider === 'mapbox-gl' && (
<>
<div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
supports3d
? 'border-slate-200 dark:border-slate-700'
@@ -396,8 +354,6 @@ export default function MapSettingsTab(): React.ReactElement {
<div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
</div>
</>
)}
</div>
)}
@@ -427,9 +383,8 @@ export default function MapSettingsTab(): React.ReactElement {
<div>
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
{provider !== 'leaflet' ? (
<GlMapPreview
provider={provider}
{provider === 'mapbox-gl' ? (
<MapboxPreview
token={mapboxToken}
style={mapboxStyle}
lat={parseFloat(String(defaultLat)) || 48.8566}
@@ -437,8 +392,8 @@ export default function MapSettingsTab(): React.ReactElement {
// Zoom in close so the style's character (3D buildings,
// satellite texture, label density) is immediately visible.
zoom={Math.max(parseInt(String(defaultZoom)) || 10, 16)}
enable3d={provider === 'mapbox-gl' && mapbox3d && supports3d}
quality={provider === 'mapbox-gl' && mapboxQuality}
enable3d={mapbox3d && supports3d}
quality={mapboxQuality}
onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }}
/>
) : (
@@ -1,14 +1,10 @@
import { useEffect, useRef } from 'react'
import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
interface Props {
provider?: GlMapProvider
token?: string
token: string
style: string
lat: number
lng: number
@@ -18,44 +14,37 @@ interface Props {
onClick?: (latlng: { lat: number; lng: number }) => void
}
export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const onClickRef = useRef(onClick)
onClickRef.current = onClick
const isMapLibre = provider === 'maplibre-gl'
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
const glStyle = normalizeStyleForProvider(provider, style)
const enableMapbox3d = !isMapLibre && enable3d
useEffect(() => {
if (!containerRef.current || (!isMapLibre && !token)) return
if (!isMapLibre) mapboxgl.accessToken = token
if (!containerRef.current || !token) return
mapboxgl.accessToken = token
const mapOptions: Record<string, unknown> = {
const map = new mapboxgl.Map({
container: containerRef.current,
style: glStyle,
style,
center: [lng, lat],
zoom,
pitch: enableMapbox3d ? 45 : 0,
pitch: enable3d ? 45 : 0,
attributionControl: true,
antialias: quality,
}
if (!isMapLibre) mapOptions.projection = quality ? 'globe' : 'mercator'
const map = new gl.Map(mapOptions as any)
projection: quality ? 'globe' : 'mercator',
})
mapRef.current = map
map.on('load', () => {
if (enableMapbox3d) {
if (!isStandardFamily(glStyle)) addTerrainAndSky(map)
if (supportsCustom3d(glStyle)) {
if (enable3d) {
if (!isStandardFamily(style)) addTerrainAndSky(map)
if (supportsCustom3d(style)) {
const dark = document.documentElement.classList.contains('dark')
addCustom3dBuildings(map, dark)
}
}
if (glStyle === MAPBOX_DEFAULT_STYLE) {
if (style === 'mapbox://styles/mapbox/standard') {
try { map.setTerrain(null) } catch { /* noop */ }
}
})
@@ -68,7 +57,7 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
try { map.remove() } catch { /* noop */ }
mapRef.current = null
}
}, [provider, token, glStyle, enableMapbox3d, quality])
}, [token, style, enable3d, quality])
// Recenter without rebuilding the map when lat/lng/zoom change externally
useEffect(() => {
@@ -76,7 +65,7 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
try { mapRef.current.jumpTo({ center: [lng, lat], zoom }) } catch { /* noop */ }
}, [lat, lng, zoom])
if (!isMapLibre && !token) {
if (!token) {
return (
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-800 text-xs text-slate-500 rounded-lg border border-slate-200 dark:border-slate-700">
Enter a Mapbox access token to preview
@@ -62,17 +62,16 @@ function CTALink({
if (notice.cta.kind === 'nav') {
navigate(notice.cta.href);
if (notice.dismissible) onDismiss();
} else if (notice.cta.kind === 'link') {
window.open(notice.cta.href, '_blank', 'noopener,noreferrer');
} else {
runNoticeAction(notice.cta.actionId, { navigate });
if (notice.cta.dismissOnAction !== false) onDismiss();
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
if (actionCta.dismissOnAction !== false) onDismiss();
}
}
if (!notice.cta) return null;
if (notice.cta.kind === 'nav' || notice.cta.kind === 'link') {
if (notice.cta.kind === 'nav') {
return (
<a
href={notice.cta.href}
@@ -1,26 +1,10 @@
import { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
import { ModalRenderer } from './SystemNoticeModal.js';
import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js';
// Mobile breakpoint matches the modal sheet's (max-width: 639px).
function useIsMobile() {
const [isMobile, setIsMobile] = useState(
() => typeof window !== 'undefined' && (window.matchMedia?.('(max-width: 639px)')?.matches ?? false)
);
useEffect(() => {
const mq = window.matchMedia?.('(max-width: 639px)');
if (!mq) return;
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return isMobile;
}
export function SystemNoticeHost() {
const { notices, loaded } = useSystemNoticeStore();
const isMobile = useIsMobile();
// Notices are fetched by authStore after login (see App.tsx / authStore modification).
// Cold-session fetch (page reload with valid session) is triggered here:
@@ -33,12 +17,9 @@ export function SystemNoticeHost() {
if (!loaded) return null;
// desktopOnly notices (e.g. the thank-you/support modal) are hidden on mobile.
const visible = isMobile ? notices.filter(n => !n.desktopOnly) : notices;
const modals = visible.filter(n => n.display === 'modal');
const banners = visible.filter(n => n.display === 'banner');
const toasts = visible.filter(n => n.display === 'toast');
const modals = notices.filter(n => n.display === 'modal');
const banners = notices.filter(n => n.display === 'banner');
const toasts = notices.filter(n => n.display === 'toast');
return (
<>
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight, Coffee } from 'lucide-react';
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';
@@ -36,33 +36,6 @@ const SEVERITY_ACCENT: Record<string, string> = {
critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950',
};
// Real brand marks (simple-icons single-path logos) for the support buttons, so the
// Buy Me a Coffee / Ko-fi buttons carry their actual logo instead of a generic
// lucide glyph. Tinted via currentColor.
const BRAND_ICON_PATHS: Record<string, string> = {
buymeacoffee:
'M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z',
kofi:
'M11.351 2.715c-2.7 0-4.986.025-6.83.26C2.078 3.285 0 5.154 0 8.61c0 3.506.182 6.13 1.585 8.493 1.584 2.701 4.233 4.182 7.662 4.182h.83c4.209 0 6.494-2.234 7.637-4a9.5 9.5 0 0 0 1.091-2.338C21.792 14.688 24 12.22 24 9.208v-.415c0-3.247-2.13-5.507-5.792-5.87-1.558-.156-2.65-.208-6.857-.208m0 1.947c4.208 0 5.09.052 6.571.182 2.624.311 4.13 1.584 4.13 4v.39c0 2.156-1.792 3.844-3.87 3.844h-.935l-.156.649c-.208 1.013-.597 1.818-1.039 2.546-.909 1.428-2.545 3.064-5.922 3.064h-.805c-2.571 0-4.831-.883-6.078-3.195-1.09-2-1.298-4.155-1.298-7.506 0-2.181.857-3.402 3.012-3.714 1.533-.233 3.559-.26 6.39-.26m6.547 2.287c-.416 0-.65.234-.65.546v2.935c0 .311.234.545.65.545 1.324 0 2.051-.754 2.051-2s-.727-2.026-2.052-2.026m-10.39.182c-1.818 0-3.013 1.48-3.013 3.142 0 1.533.858 2.857 1.949 3.897.727.701 1.87 1.429 2.649 1.896a1.47 1.47 0 0 0 1.507 0c.78-.467 1.922-1.195 2.623-1.896 1.117-1.039 1.974-2.364 1.974-3.897 0-1.662-1.247-3.142-3.039-3.142-1.065 0-1.792.545-2.338 1.298-.493-.753-1.246-1.298-2.312-1.298',
};
function brandForHref(href?: string): string | null {
if (!href) return null;
if (href.includes('buymeacoffee')) return 'buymeacoffee';
if (href.includes('ko-fi.com') || href.includes('kofi')) return 'kofi';
return null;
}
function BrandIcon({ brand, size = 18, className }: { brand: string; size?: number; className?: string }) {
const d = BRAND_ICON_PATHS[brand];
if (!d) return null;
return (
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" className={className} aria-hidden="true">
<path d={d} />
</svg>
);
}
interface Props {
notices: SystemNoticeDTO[];
}
@@ -73,14 +46,12 @@ interface ContentProps {
title: string;
body: string;
ctaLabel: string | null;
secondaryCtaLabel: string | null;
titleId: string;
bodyId: string;
isDark: boolean;
onDismiss: () => void;
onDismissAll: () => void;
onCTA: () => void;
onSecondaryCTA: () => void;
// Pager
total: number;
currentPage: number;
@@ -90,7 +61,7 @@ interface ContentProps {
onGoto: (i: number) => void;
}
function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, onSecondaryCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
const { t } = useTranslation();
const isLastPage = total <= 1 || currentPage === total - 1;
@@ -99,10 +70,6 @@ function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, title
? ((LucideIcons as Record<string, unknown>)[notice.icon] as React.ElementType) ?? DefaultIcon
: DefaultIcon;
// Real brand logo for each support button, detected from the link target.
const primaryBrand = notice.cta?.kind === 'link' ? brandForHref(notice.cta.href) : null;
const secondaryBrand = notice.secondaryCta?.kind === 'link' ? brandForHref(notice.secondaryCta.href) : null;
return (
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
{/* Dismiss X button — only on last page so users read all notices */}
@@ -137,9 +104,17 @@ function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, title
{/* Special warm header for Heart icon (thank-you notice) */}
{notice.icon === 'Heart' && !notice.media && (
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-6 text-center">
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
<h2 id={titleId} className="relative text-xl font-bold text-white leading-tight">{title}</h2>
<div className="relative flex items-center justify-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
<LucideIcon size={20} className="text-white" />
</div>
<div className="text-left">
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
</div>
</div>
</div>
)}
@@ -222,27 +197,24 @@ function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, title
</div>
)}
{/* Highlights — compact pills */}
{/* Highlights */}
{notice.highlights && notice.highlights.length > 0 && (
<div className="flex flex-wrap justify-center gap-2 mb-4">
<ul className="mx-auto mb-4 space-y-2">
{notice.highlights.map((h, i) => {
const HIcon: React.ElementType | null = h.iconName
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
: null;
return (
<span
key={i}
className="inline-flex items-center gap-1.5 rounded-full bg-slate-100 dark:bg-slate-800 px-3 py-1 text-xs font-medium text-slate-700 dark:text-slate-300"
>
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
{HIcon
? <HIcon size={13} className="text-indigo-500 dark:text-indigo-400 shrink-0" />
: <span className="text-indigo-500 shrink-0"></span>
? <HIcon size={16} className="text-blue-500 shrink-0" />
: <span className="text-blue-500 shrink-0"></span>
}
{t(h.labelKey)}
</span>
</li>
);
})}
</div>
</ul>
)}
</div>
</div>
@@ -298,37 +270,16 @@ function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, title
</div>
)}
{/* CTA(s) + dismiss link */}
{/* CTA + dismiss link */}
<div className="flex flex-col items-center gap-3">
{ctaLabel && isLastPage ? (
<div className="flex w-full flex-col sm:flex-row gap-2.5">
<button
id={`notice-cta-${notice.id}`}
onClick={onCTA}
className={`flex-1 h-11 inline-flex items-center justify-center gap-2 rounded-lg font-semibold shadow-sm transition active:scale-[0.98] ${
notice.cta?.kind === 'link'
? 'bg-[#FFDD00] text-[#0D0C22] hover:brightness-95'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
>
{primaryBrand ? <BrandIcon brand={primaryBrand} size={18} /> : (notice.cta?.kind === 'link' && <Coffee size={17} aria-hidden="true" />)}
{ctaLabel}
</button>
{secondaryCtaLabel && (
<button
id={`notice-cta2-${notice.id}`}
onClick={onSecondaryCTA}
className={`flex-1 h-11 inline-flex items-center justify-center gap-2 rounded-lg font-semibold shadow-sm transition active:scale-[0.98] ${
notice.secondaryCta?.kind === 'link'
? 'bg-[#FF5E5B] text-white hover:brightness-95'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
>
{secondaryBrand ? <BrandIcon brand={secondaryBrand} size={18} /> : (notice.secondaryCta?.kind === 'link' && <Coffee size={17} aria-hidden="true" />)}
{secondaryCtaLabel}
</button>
)}
</div>
<button
id={`notice-cta-${notice.id}`}
onClick={onCTA}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
>
{ctaLabel}
</button>
) : (notice.dismissible || isLastPage) && (
<button
id={`notice-cta-${notice.id}`}
@@ -338,6 +289,14 @@ function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, title
{t('common.ok')}
</button>
)}
{notice.dismissible && isLastPage && ctaLabel && (
<button
onClick={onDismiss}
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
>
Not now
</button>
)}
</div>
</div>
</div>
@@ -551,22 +510,21 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
notices.forEach(n => dismiss(n.id));
}
function runCta(cta: SystemNoticeDTO['cta']) {
if (!cta) { handleDismissAll(); return; }
if (cta.kind === 'nav') {
navigate(cta.href);
if (notice?.dismissible !== false) handleDismissAll();
} else if (cta.kind === 'link') {
// External link (e.g. Buy Me a Coffee / Ko-fi): open in a new tab and leave the
// notice open so the user can use the other button too.
window.open(cta.href, '_blank', 'noopener,noreferrer');
function handleCTA() {
if (!notice) return;
if (!notice.cta) {
handleDismissAll();
return;
}
if (notice.cta.kind === 'nav') {
navigate(notice.cta.href);
if (notice.dismissible !== false) handleDismissAll();
} else {
runNoticeAction(cta.actionId, { navigate });
if (cta.dismissOnAction !== false) handleDismissAll();
runNoticeAction(notice.cta.actionId, { navigate });
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
if (actionCta.dismissOnAction !== false) handleDismissAll();
}
}
function handleCTA() { runCta(notice?.cta); }
function handleSecondaryCTA() { runCta(notice?.secondaryCta); }
function animatedDismissAll() {
const sheet = sheetRef.current;
@@ -626,7 +584,7 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
notice, canPage, isLastPage, language, t, dur, ease,
touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, isPageNavRef,
stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef,
announceIndex, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, animatedDismissAll,
announceIndex, handleDismiss, handleDismissAll, handleCTA, animatedDismissAll,
handlePrev, handleNext, handleGoto,
};
}
@@ -635,7 +593,7 @@ type NoticeState = ReturnType<typeof useSystemNoticeModal>;
// Build the NoticeContent props for a given notice + pager slot index.
function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps {
const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S;
const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handlePrev, handleNext, handleGoto } = S;
const rawBody = t(n.bodyKey);
const body = n.bodyParams
? Object.entries(n.bodyParams).reduce(
@@ -648,14 +606,12 @@ function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number):
title: t(n.titleKey),
body,
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
secondaryCtaLabel: n.secondaryCta ? t(n.secondaryCta.labelKey) : null,
titleId: `notice-title-${n.id}`,
bodyId: `notice-body-${n.id}`,
isDark,
onDismiss: handleDismiss,
onDismissAll: handleDismissAll,
onCTA: handleCTA,
onSecondaryCTA: handleSecondaryCTA,
total: notices.length,
currentPage: slotIdx,
canPage,
@@ -69,7 +69,7 @@ export default function VacayCalendar() {
return (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" style={{ paddingBottom: 'calc(var(--bottom-nav-h, 0px) + 80px)' }}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
{Array.from({ length: 12 }, (_, i) => (
<VacayMonthCard
key={i}
@@ -89,8 +89,8 @@ export default function VacayCalendar() {
))}
</div>
{/* Floating toolbar — lift above the mobile bottom nav (z-60). On desktop --bottom-nav-h is 0px. */}
<div className="sticky mt-3 sm:mt-4 flex items-center justify-center px-2" style={{ bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', zIndex: 61 }}>
{/* Floating toolbar */}
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
<button
onClick={() => setCompanyMode(false)}
+1 -3
View File
@@ -102,9 +102,7 @@ export function ToastContainer() {
`}</style>
<div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
// Above modal overlays (which sit around z-index 10000 with a backdrop-filter
// blur) so error toasts paint on top and stay legible instead of blurred behind.
zIndex: 100000, display: 'flex', flexDirection: 'column-reverse', gap: 8,
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
}}>
{toasts.map(toast => (
-48
View File
@@ -60,15 +60,6 @@ export interface BlobCacheEntry {
cachedAt: number;
}
/** An uploaded booking-import source file, kept so the review flow can attach it to the
* created bookings even after a page reload during the (background) parse. Keyed by job. */
export interface ImportSourceFile {
jobId: string;
fileName: string;
blob: Blob;
createdAt: number;
}
// ── Dexie class ────────────────────────────────────────────────────────────────
/**
@@ -114,7 +105,6 @@ class TrekOfflineDb extends Dexie {
mutationQueue!: Table<QueuedMutation, string>;
syncMeta!: Table<SyncMeta, number>;
blobCache!: Table<BlobCacheEntry, string>;
importFiles!: Table<ImportSourceFile, [string, string]>;
constructor(name: string = ANON_DB_NAME) {
super(name);
@@ -150,11 +140,6 @@ class TrekOfflineDb extends Dexie {
if (row.bytes == null) row.bytes = row.blob?.size ?? 0;
});
});
// v4: durable store for booking-import source files (survives a reload mid-parse).
this.version(4).stores({
importFiles: '[jobId+fileName], jobId, createdAt',
});
}
}
@@ -279,39 +264,6 @@ export async function getCachedBlob(url: string): Promise<Blob | null> {
}
}
// ── Booking-import source files ─────────────────────────────────────────────
/** Abandoned import files (never reviewed) are pruned after this long. */
const IMPORT_FILE_TTL_MS = 60 * 60_000;
/**
* Persist the uploaded source files for a background import job so the per-item review can
* attach each document to its booking even if the page reloads during the parse. Best-effort.
*/
export async function saveImportFiles(jobId: string, files: File[]): Promise<void> {
try {
const now = Date.now();
await offlineDb.importFiles.bulkPut(files.map(f => ({ jobId, fileName: f.name, blob: f, createdAt: now })));
// Prune leftovers from imports that were never reviewed.
await offlineDb.importFiles.where('createdAt').below(now - IMPORT_FILE_TTL_MS).delete();
} catch { /* the in-memory copy still serves the no-reload path */ }
}
/** A job's stored source files, rebuilt as File objects (name + type preserved for upload). */
export async function getImportFiles(jobId: string): Promise<File[]> {
try {
const rows = await offlineDb.importFiles.where('jobId').equals(jobId).toArray();
return rows.map(r => new File([r.blob], r.fileName, { type: r.blob.type || 'application/octet-stream' }));
} catch {
return [];
}
}
/** Drop a job's stored source files once they've been handed to the review flow. */
export async function deleteImportFiles(jobId: string): Promise<void> {
try { await offlineDb.importFiles.where('jobId').equals(jobId).delete(); } catch { /* ignore */ }
}
// ── Blob-cache budget ───────────────────────────────────────────────────────
/**
+8 -63
View File
@@ -1,33 +1,23 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
import { calculateRouteWithLegs, withHotelBookends } from '../components/Map/RouteCalculator'
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
import { getTransportRouteEndpoints } from '../utils/dayMerge'
import { getDayBookendHotels } from '../utils/dayOrder'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult, Accommodation } from '../types'
import type { RouteSegment, RouteResult } from '../types'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
const NO_ACCOMMODATIONS: Accommodation[] = []
/**
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
*/
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
const [route, setRoute] = useState<[number, number][][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations)
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
// hotel) unless the user turned the setting off — same gate as the sidebar.
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation)
// Recompute when the user flips km↔mi so leg distances (formatted at compute time)
// refresh instead of showing stale cached text (#1300).
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit)
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
@@ -103,55 +93,10 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
}
if (currentRun.length >= 2) runs.push(currentRun)
// Bookend the route with the day's accommodation: a hotel → first-stop run and
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
// transfer day) and already filters to accommodations that have coordinates.
const day = allDays.find(d => d.id === dayId)
const bookends = day && optimizeFromAccommodation !== false
? getDayBookendHotels(day, allDays, accommodations)
: null
const flatPts: { lat: number; lng: number }[] = []
for (const e of entries) {
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng })
else { if (e.from) flatPts.push(e.from); if (e.to) flatPts.push(e.to) }
}
const hotelPt = (a?: Accommodation) =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null
// Only draw a hotel bookend when the leg is real. A hotel → first-stop leg holds
// if the first stop is a place, or if you actually slept in that hotel last night;
// on a day-1 arrival the morning hotel is just a check-in fallback and the first
// waypoint is the transport's departure point, so [hotel → departure] is dropped
// (#1321). Symmetrically, [last-stop → hotel] is dropped when you leave on a transport
// in the evening and don't sleep in that hotel tonight.
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
const firstStop = entries.find(contributes)
const lastStop = [...entries].reverse().find(contributes)
const drawMorning = firstStop?.kind === 'place' || !!bookends?.morningIsSleptHere
const drawEvening = lastStop?.kind === 'place' || !!bookends?.eveningIsOvernight
const runsWithHotel = withHotelBookends(
runs,
flatPts[0],
flatPts[flatPts.length - 1],
drawMorning ? hotelPt(bookends?.morning) : null,
drawEvening ? hotelPt(bookends?.evening) : null,
)
// Transfer day with no activities: you check out of one accommodation and into
// another, so there are no waypoints for withHotelBookends to attach a leg to.
// Draw the hotel → hotel transfer directly. Gated on both bookends being real
// (drawMorning/drawEvening already exclude the #1321 arrival fallback) and the two
// hotels being distinct, so an ordinary same-hotel rest day still draws nothing.
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
const m = hotelPt(bookends?.morning)
const e = hotelPt(bookends?.evening)
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
}
const straightLines = (): [number, number][][] =>
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); return }
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
// Draw straight lines immediately for snappiness, then upgrade to the real
// OSRM road geometry.
@@ -162,7 +107,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
try {
const polylines: [number, number][][] = []
const allLegs: RouteSegment[] = []
for (const run of runsWithHotel) {
for (const run of runs) {
try {
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
@@ -178,7 +123,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
}
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
}, [enabled, profile])
// Stable signature for transport reservations on the selected day — changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
@@ -202,7 +147,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
-1
View File
@@ -37,7 +37,6 @@ const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: Tran
ko: () => import('@trek/shared/i18n/ko'),
uk: () => import('@trek/shared/i18n/uk'),
gr: () => import('@trek/shared/i18n/gr'),
sv: () => import('@trek/shared/i18n/sv'),
}
// Re-export pure helpers that live in shared so downstream consumers can import them
+3 -5
View File
@@ -35,19 +35,17 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
color: var(--text-primary) !important;
}
/* GL hover popup the name/category/address card on marker hover.
/* Mapbox GL hover popup the name/category/address card on marker hover.
Matches the Leaflet map's white hover tooltip. pointer-events:none so moving
onto the popup never steals the marker's mouseleave and causes flicker. */
.trek-map-popup { pointer-events: none; }
.trek-map-popup .mapboxgl-popup-content,
.trek-map-popup .maplibregl-popup-content {
.trek-map-popup .mapboxgl-popup-content {
padding: 7px 10px;
border-radius: 10px;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16);
}
.trek-map-popup .mapboxgl-popup-tip,
.trek-map-popup .maplibregl-popup-tip {
.trek-map-popup .mapboxgl-popup-tip {
border-top-color: #fff;
border-bottom-color: #fff;
border-left-color: #fff;
+2 -7
View File
@@ -6,7 +6,6 @@ import CustomSelect from '../components/shared/CustomSelect'
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
import type { TranslationFn } from '../types'
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { continentForCountry } from '@trek/shared'
import { useAtlas } from './atlas/useAtlas'
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
import { useToast } from '../components/shared/Toast'
@@ -213,8 +212,7 @@ export default function AtlasPage(): React.ReactElement {
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
setData(prev => {
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
const cont = continentForCountry(confirmAction.code)
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
})
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
@@ -262,8 +260,7 @@ export default function AtlasPage(): React.ReactElement {
})
setData(prev => {
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
const cont = continentForCountry(countryCode)
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
})
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
@@ -342,12 +339,10 @@ export default function AtlasPage(): React.ReactElement {
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
if (remainingRegions.length > 0) return prev
const cont = continentForCountry(countryCode)
return {
...prev,
countries: prev.countries.filter(c => c.code !== countryCode),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
}
})
} catch (err) {
+2 -73
View File
@@ -4,10 +4,9 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip, buildSettings } from '../../tests/helpers/factories';
import { buildUser, buildAdmin, buildTrip } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { usePermissionsStore } from '../store/permissionsStore';
import { useSettingsStore } from '../store/settingsStore';
import DashboardPage from './DashboardPage';
beforeEach(() => {
@@ -799,51 +798,10 @@ describe('DashboardPage', () => {
});
});
describe('FE-PAGE-DASH-033: Atlas distance respects distance unit setting', () => {
const distanceValue = (text: string) =>
screen.getByText((_, element) =>
element?.classList.contains('value') === true &&
element.textContent?.replace(/\s+/g, ' ').trim() === text
);
beforeEach(() => {
server.use(
http.get('/api/auth/travel-stats', () =>
HttpResponse.json({
totalTrips: 1,
totalDays: 1,
totalPlaces: 1,
totalDistanceKm: 10,
countries: [],
})
),
);
});
it('renders metric atlas distance as kilometers', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
render(<DashboardPage />);
await waitFor(() => {
expect(distanceValue('10 km')).toBeInTheDocument();
});
});
it('renders imperial atlas distance as miles', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
render(<DashboardPage />);
await waitFor(() => {
expect(distanceValue('6.2 mi')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-DASH-032: Dark mode detection uses window.matchMedia', () => {
it('renders without error when dark_mode is set to auto', async () => {
// Seed settings with dark_mode = 'auto' to exercise the matchMedia branch
const { useSettingsStore } = await import('../store/settingsStore');
seedStore(useSettingsStore, {
settings: {
map_tile_url: '',
@@ -854,7 +812,6 @@ describe('DashboardPage', () => {
default_currency: 'USD',
language: 'en',
temperature_unit: 'fahrenheit',
distance_unit: 'metric',
time_format: '12h',
show_place_description: false,
blur_booking_codes: false,
@@ -874,32 +831,4 @@ describe('DashboardPage', () => {
expect(screen.getByText(/my trips/i)).toBeInTheDocument();
});
});
describe('FE-PAGE-DASH-034: dashboard widgets persist to settings, not localStorage (#1311)', () => {
it('reads the timezone widget zones from the settings store', async () => {
// A zone that is NOT in the hardcoded default ([home, London, Tokyo]) — its presence
// proves the widget reads the stored preference rather than the old localStorage default.
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['America/New_York'] }), isLoaded: true });
render(<DashboardPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /add timezone/i })).toBeInTheDocument());
expect(screen.getByText('New York')).toBeInTheDocument();
});
it('migrates the pre-3.1.3 localStorage prefs into settings and clears the legacy keys', async () => {
localStorage.setItem('trek_fx_from', 'CAD');
localStorage.setItem('trek_fx_to', 'CHF');
localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<DashboardPage />);
// The one-time migration runs on mount (settings already loaded) and removes the keys.
await waitFor(() => {
expect(localStorage.getItem('trek_fx_from')).toBeNull();
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
});
const s = useSettingsStore.getState().settings;
expect(s.dashboard_fx_from).toBe('CAD');
expect(s.dashboard_fx_to).toBe('CHF');
expect(s.dashboard_timezones).toEqual(['America/New_York']);
});
});
});
+20 -85
View File
@@ -18,9 +18,6 @@ import {
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
LayoutGrid, List, Ticket, X,
} from 'lucide-react'
import { formatTime, splitReservationDateTime } from '../utils/formatters'
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
import { useSettingsStore } from '../store/settingsStore'
import '../styles/dashboard.css'
const GRADIENTS = [
@@ -39,7 +36,6 @@ function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.leng
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string } | null {
if (!dateStr) return null
const date = new Date(dateStr + 'T00:00:00Z')
if (isNaN(date.getTime())) return null // malformed date — render a dash, never crash
return {
d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }),
m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }),
@@ -85,7 +81,6 @@ export default function DashboardPage(): React.ReactElement {
const {
demoMode, locale, t, navigate,
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
loadError, retryLoad,
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
@@ -104,15 +99,6 @@ export default function DashboardPage(): React.ReactElement {
<MobileTopBar />
<main className="page">
<div className="page-main">
{loadError && (
<div className="dash-error" role="alert">
<span className="dash-error-txt">{t('dashboard.loadErrorBanner')}</span>
<button className="dash-error-retry" onClick={retryLoad}>
<RefreshCw size={15} />
{t('dashboard.retry')}
</button>
</div>
)}
{spotlight && (
<BoardingPassHero
trip={spotlight}
@@ -143,13 +129,6 @@ export default function DashboardPage(): React.ReactElement {
</div>
</div>
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
<div className="trips-empty">
<h4>{t('dashboard.emptyTitle')}</h4>
<p>{t('dashboard.emptyText')}</p>
</div>
)}
<div className={`trips${viewMode === 'list' ? ' list-view' : ''}`}>
{gridTrips.map(trip => (
<TripCard
@@ -359,27 +338,12 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
}
// ── Atlas / stats row ────────────────────────────────────────────────────────
function formatCompactDistance(value: number): string {
const safeValue = Number.isFinite(value) ? Math.max(0, value) : 0
// String() keeps a '.' decimal regardless of locale (no "1,5k" in non-English UIs).
if (safeValue >= 1000) {
return `${String(Math.round(safeValue / 100) / 10)}k`
}
const rounded = Math.round(safeValue * 10) / 10
if (safeValue > 0 && rounded === 0) return '<0.1'
return String(rounded)
}
function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
const countries = stats?.countries || []
const distanceKm = stats?.totalDistanceKm || 0
const distance = convertDistance(distanceKm, distanceUnit)
const distanceText = formatCompactDistance(distance)
const equatorDistance = convertDistance(40075, distanceUnit)
const equatorTimes = (distance / equatorDistance).toFixed(2)
const distanceLabel = getDistanceUnitLabel(distanceUnit)
const distanceText = distanceKm >= 1000 ? `${(distanceKm / 1000).toFixed(1)}k` : String(distanceKm)
const equatorTimes = (distanceKm / 40075).toFixed(2)
return (
<section className="atlas">
@@ -417,7 +381,7 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.distanceFlown')}</div>
<div className="value mono">{distanceText} <span className="unit">{distanceLabel}</span></div>
<div className="value mono">{distanceText} <span className="unit">{t('dashboard.atlas.kmUnit')}</span></div>
<div className="delta">{t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<circle cx="40" cy="18" r="14" fill="none" stroke="oklch(0.88 0.01 70)" strokeWidth="2" />
@@ -491,12 +455,8 @@ const FX_FALLBACK = ['EUR', 'USD', 'GBP', 'CHF', 'JPY', 'CAD', 'AUD', 'CNY', 'SE
function CurrencyTool(): React.ReactElement {
const { t } = useTranslation()
const isLoaded = useSettingsStore(s => s.isLoaded)
const updateSetting = useSettingsStore(s => s.updateSetting)
const from = useSettingsStore(s => s.settings.dashboard_fx_from) || 'EUR'
const to = useSettingsStore(s => s.settings.dashboard_fx_to) || 'USD'
const setFrom = (v: string) => { updateSetting('dashboard_fx_from', v).catch(() => {}) }
const setTo = (v: string) => { updateSetting('dashboard_fx_to', v).catch(() => {}) }
const [from, setFrom] = useState(() => localStorage.getItem('trek_fx_from') || 'EUR')
const [to, setTo] = useState(() => localStorage.getItem('trek_fx_to') || 'USD')
const [amount, setAmount] = useState('100')
const [rates, setRates] = useState<Record<string, number> | null>(null)
@@ -514,18 +474,7 @@ function CurrencyTool(): React.ReactElement {
}, [from])
useEffect(() => { fetchRate() }, [fetchRate])
// One-time migration of the pre-3.1.3 localStorage values into the user's settings,
// so a (docker) upgrade no longer resets the widget (#1311).
useEffect(() => {
if (!isLoaded) return
const lf = localStorage.getItem('trek_fx_from')
const lt = localStorage.getItem('trek_fx_to')
if (!lf && !lt) return
if (lf) updateSetting('dashboard_fx_from', lf).catch(() => {})
if (lt) updateSetting('dashboard_fx_to', lt).catch(() => {})
localStorage.removeItem('trek_fx_from')
localStorage.removeItem('trek_fx_to')
}, [isLoaded, updateSetting])
useEffect(() => { localStorage.setItem('trek_fx_from', from); localStorage.setItem('trek_fx_to', to) }, [from, to])
const currencies = rates ? Object.keys(rates).sort() : FX_FALLBACK
const ccyOptions = currencies.map(c => ({ value: c, label: c }))
@@ -580,12 +529,13 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
const { t } = useTranslation()
const home = Intl.DateTimeFormat().resolvedOptions().timeZone
const [now, setNow] = useState(() => new Date())
const isLoaded = useSettingsStore(s => s.isLoaded)
const updateSetting = useSettingsStore(s => s.updateSetting)
const stored = useSettingsStore(s => s.settings.dashboard_timezones)
// Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
const zones = stored ?? [home, ...DEFAULT_ZONES]
const setZones = (next: string[]) => { updateSetting('dashboard_timezones', next).catch(() => {}) }
const [zones, setZones] = useState<string[]>(() => {
try {
const raw = localStorage.getItem('trek_dashboard_tz')
if (raw) return JSON.parse(raw)
} catch { /* ignore malformed storage */ }
return [home, ...DEFAULT_ZONES]
})
const [adding, setAdding] = useState(false)
// A minute's resolution is plenty for clocks and keeps re-renders cheap.
@@ -594,18 +544,7 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
return () => clearInterval(id)
}, [])
// One-time migration of the pre-3.1.3 localStorage value into the user's settings,
// so a (docker) upgrade no longer resets the widget (#1311).
useEffect(() => {
if (!isLoaded) return
const raw = localStorage.getItem('trek_dashboard_tz')
if (!raw) return
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) updateSetting('dashboard_timezones', parsed).catch(() => {})
} catch { /* ignore malformed storage */ }
localStorage.removeItem('trek_dashboard_tz')
}, [isLoaded, updateSetting])
useEffect(() => { localStorage.setItem('trek_dashboard_tz', JSON.stringify(zones)) }, [zones])
const allZones = React.useMemo<string[]>(() => {
const supported = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf
@@ -616,8 +555,8 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
.filter(z => !zones.includes(z))
.map(z => ({ value: z, label: z.replace(/_/g, ' '), searchLabel: z }))
const addZone = (tz: string) => { if (tz && !zones.includes(tz)) setZones([...zones, tz]); setAdding(false) }
const removeZone = (tz: string) => setZones(zones.filter(z => z !== tz))
const addZone = (tz: string) => { if (tz) setZones(prev => prev.includes(tz) ? prev : [...prev, tz]); setAdding(false) }
const removeZone = (tz: string) => setZones(prev => prev.filter(z => z !== tz))
const timeIn = (tz: string) => now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: tz })
const offsetLabel = (tz: string) => {
@@ -663,7 +602,6 @@ function UpcomingTool({ items, locale, onOpen }: {
items: UpcomingReservation[]; locale: string; onOpen: (tripId: number) => void
}): React.ReactElement {
const { t } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format)
return (
<div className="tool">
<div className="tool-head">
@@ -674,13 +612,10 @@ function UpcomingTool({ items, locale, onOpen }: {
) : (
<div className="upc-list">
{items.map(r => {
// Read the date/time straight from the stored string parts. Going through
// new Date(...).toISOString() reinterprets the naive local time as UTC and
// can roll the displayed day forward/back in non-UTC timezones.
const parsed = splitReservationDateTime(r.reservation_time)
const datePart = parsed.date || r.day_date || null
const dateStr = datePart ? splitDate(datePart, locale) : null
const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null
const when = r.reservation_time || (r.day_date ? r.day_date + 'T00:00:00' : null)
const d = when ? new Date(when) : null
const dateStr = d ? splitDate(d.toISOString().slice(0, 10), locale) : null
const timeStr = r.reservation_time ? new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : null
const typeClass = RES_TYPE_CLASS[r.type] || 'other'
return (
<div className="upc-item" key={r.id} onClick={() => onOpen(r.trip_id)}>
+1 -30
View File
@@ -3,9 +3,7 @@ import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
import { Routes, Route } from 'react-router-dom';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildSettings } from '../../tests/helpers/factories';
import { useSettingsStore } from '../store/settingsStore';
import { resetAllStores } from '../../tests/helpers/store';
import SharedTripPage from './SharedTripPage';
// Mock react-leaflet (SharedTripPage renders a map)
@@ -482,31 +480,4 @@ describe('SharedTripPage', () => {
expect(screen.getByText(/LH2/)).toBeInTheDocument();
});
});
describe('FE-PAGE-SHARED-018: untitled day uses the translated day label (#1296)', () => {
it('renders the day-number label via i18n (German), not a hardcoded English string', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de' }) });
const day = { id: 101, trip_id: 1, day_number: 1, date: '2026-07-01', title: null, notes: null };
server.use(
http.get('/api/shared/:token', () => HttpResponse.json({
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
days: [day],
assignments: {},
dayNotes: {},
places: [],
reservations: [],
accommodations: [],
packing: [],
budget: [],
categories: [],
permissions: { share_bookings: false, share_packing: false, share_budget: false, share_collab: false },
collab: [],
})),
);
renderSharedTrip('test-token');
// The untitled day shows the German label "Tag 1", proving the hardcoded English
// "Day 1" was replaced by the i18n key t('dayplan.dayN').
await waitFor(() => expect(screen.getByText('Tag 1')).toBeInTheDocument());
});
});
});
+1 -1
View File
@@ -196,7 +196,7 @@ export default function SharedTripPage() {
style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
<div className={selectedDay === day.id ? 'bg-[#111827] text-white' : 'bg-[#f3f4f6] text-[#6b7280]'} style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
<div style={{ flex: 1 }}>
<div className="text-[#111827]" style={{ fontSize: 14, fontWeight: 600 }}>{day.title || t('dayplan.dayN', { n: day.day_number })}</div>
<div className="text-[#111827]" style={{ fontSize: 14, fontWeight: 600 }}>{day.title || `Day ${day.day_number}`}</div>
{day.date && <div className="text-[#9ca3af]" style={{ fontSize: 11, marginTop: 1 }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>}
</div>
{dayAccs.map((acc: any) => (
+5 -12
View File
@@ -1160,13 +1160,10 @@ describe('TripPlannerPage', () => {
});
describe('FE-PAGE-PLANNER-041: handleSaveReservation edit path covers update reservation', () => {
it('does not force a day_id on edit so the server keeps/derives it (#1237)', async () => {
it('calls onEdit then onSave on ReservationModal to exercise the edit-reservation handler', async () => {
vi.useFakeTimers();
seedTripStore({ id: 42 });
// Capture the update payload — tripActions is a snapshot of the store at mount.
const updateReservationSpy = vi.fn().mockResolvedValue({ id: 1, day_id: 7 });
seedStore(useTripStore, { updateReservation: updateReservationSpy } as any);
renderPlannerPage(42);
@@ -1182,24 +1179,20 @@ describe('TripPlannerPage', () => {
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
});
// Edit a reservation that lives on day 7 (no day is selected — Book tab).
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'other', status: 'confirmed', day_id: 7 };
// Set editingReservation via captured onEdit prop (inline lambda in JSX)
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'restaurant', status: 'confirmed' };
await act(async () => {
capturedReservationsPanelProps.current.onEdit?.(fakeReservation);
});
// Call onSave — now takes edit path (editingReservation is set)
await act(async () => {
await capturedReservationModalProps.current.onSave?.({
name: 'Updated Booking',
type: 'tour',
type: 'restaurant',
status: 'confirmed',
});
});
// The client must NOT send a day_id (no forcing to the selected day, no
// stale value) — the server keeps/derives it from the booking's date.
expect(updateReservationSpy).toHaveBeenCalled();
expect(updateReservationSpy.mock.calls[0][2]).not.toHaveProperty('day_id');
});
});
+35 -40
View File
@@ -1,11 +1,11 @@
import React, { useState } from 'react'
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import ReactDOM from 'react-dom'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore'
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
import { MapCompassPill } from '../components/Map/MapCompassPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
@@ -25,9 +25,7 @@ import PackingListPanel from '../components/Packing/PackingListPanel'
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
import TodoListPanel from '../components/Todo/TodoListPanel'
import FileManager from '../components/Files/FileManager'
import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Budget/CostsPanel'
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
import type { BudgetItem } from '../types'
import CostsPanel from '../components/Budget/CostsPanel'
import CollabPanel from '../components/Collab/CollabPanel'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
@@ -35,6 +33,7 @@ import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen,
import { useTranslation } from '../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
import { accommodationRepo } from '../repo/accommodationRepo'
import { offlineDb } from '../db/offlineDb'
import { useAuthStore } from '../store/authStore'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { useResizablePanels } from '../hooks/useResizablePanels'
@@ -194,7 +193,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
reservationPrefill, transportPrefill, importReviewActive, advanceImportReview,
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
@@ -203,7 +201,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
@@ -211,21 +209,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
} = useTripPlanner()
const poi = usePoiExplore()
const [glMap, setGlMap] = useState<CompassMap | null>(null)
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
// page level so it has tripMembers / base currency / current user available.
const meId = useAuthStore(s => s.user?.id ?? -1)
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
const loadBudgetItems = useTripStore(s => s.loadBudgetItems)
const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null)
const openBookingExpense = (req: BookingExpenseRequest) => {
if (req.editItem) setBookingExpense({ editing: req.editItem })
else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill })
}
if (isLoading || !splashDone) {
return (
<div className="bg-surface" style={{
@@ -465,7 +451,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onPlaceClick={handlePlaceClick}
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
onAssignToDay={handleAssignToDay}
onEditPlace={(place) => openPlaceEditor(place)}
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
onCategoryFilterChange={setMapCategoryFilter}
@@ -531,7 +517,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
assignments={assignments}
reservations={reservations}
onClose={() => setSelectedPlaceId(null)}
onEdit={() => openPlaceEditor(selectedPlace, selectedAssignmentId)}
onEdit={() => {
if (selectedAssignmentId) {
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
setEditingPlace(placeWithAssignmentTimes)
} else {
setEditingPlace(selectedPlace)
}
setEditingAssignmentId(selectedAssignmentId || null)
setShowPlaceForm(true)
}}
onDelete={() => handleDeletePlace(selectedPlace.id)}
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
@@ -569,7 +565,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
assignments={assignments}
reservations={reservations}
onClose={() => setSelectedPlaceId(null)}
onEdit={() => { openPlaceEditor(selectedPlace, selectedAssignmentId); setSelectedPlaceId(null) }}
onEdit={() => {
if (selectedAssignmentId) {
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
setEditingPlace(placeWithAssignmentTimes)
} else {
setEditingPlace(selectedPlace)
}
setEditingAssignmentId(selectedAssignmentId || null)
setShowPlaceForm(true)
setSelectedPlaceId(null)
}}
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
@@ -610,7 +617,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
</div>
@@ -696,24 +703,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
</div>
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
{bookingExpense && (
<ExpenseModal
tripId={tripId}
base={costsBase}
people={tripMembers}
me={meId}
editing={bookingExpense.editing}
prefill={bookingExpense.prefill}
onClose={() => setBookingExpense(null)}
onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }}
/>
)}
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
<ConfirmDialog
isOpen={!!deletePlaceId}
+5 -18
View File
@@ -229,24 +229,12 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{(updateInfo?.is_docker === false ? t('admin.update.nonDockerText') : t('admin.update.dockerText')).replace('{version}', `v${updateInfo?.latest ?? ''}`)}
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
</p>
{updateInfo?.is_docker === false ? (
<a
href="https://github.com/mauriceboe/TREK/wiki/Updating"
target="_blank"
rel="noopener noreferrer"
style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.5, display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none' }}
className="bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<ExternalLink className="w-4 h-4 flex-shrink-0" />
<span className="font-semibold underline">{t('admin.update.wikiLink')}</span>
</a>
) : (
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
{`docker pull mauriceboe/trek:latest
docker stop trek && docker rm trek
docker run -d --name trek \\
@@ -255,8 +243,7 @@ docker run -d --name trek \\
-v /opt/trek/uploads:/app/uploads \\
--restart unless-stopped \\
mauriceboe/trek:latest`}
</div>
)}
</div>
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
+4 -29
View File
@@ -6,7 +6,6 @@ import apiClient, { mapsApi } from '../../api/client'
import L from 'leaflet'
import type { GeoJsonFeatureCollection } from '../../types'
import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
import { continentForCountry } from '@trek/shared'
function useCountryNames(language: string): (code: string) => string {
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
@@ -134,12 +133,9 @@ export function useAtlas() {
}, [])
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
// no third-party fetch from the browser). Even gzipped the payload is a few MB, so
// it gets a longer timeout than the global 8s default to survive slow links and
// reverse-proxy / Cloudflare-Tunnel setups instead of aborting and leaving the map
// with no countries (#1254).
// no third-party fetch from the browser).
useEffect(() => {
apiClient.get('/addons/atlas/countries/geo', { timeout: 30000 })
apiClient.get('/addons/atlas/countries/geo')
.then(res => {
const geo = res.data
// Dynamically build A2→A3 mapping from GeoJSON
@@ -344,10 +340,7 @@ export function useAtlas() {
</div>
</div>`
layer.bindTooltip(tooltipHtml, {
// sticky so the tooltip tracks the cursor; non-sticky anchors it at the feature's
// bounds centre, which for countries with overseas territories (e.g. France) lands
// far out in the ocean instead of over the area being hovered.
sticky: true, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => {
if (c.placeCount === 0 && c.tripCount === 0) {
@@ -370,7 +363,7 @@ export function useAtlas() {
country_layer_by_a2_ref.current[countryCode] = layer
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
sticky: true, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => handleMarkCountry(countryCode, name))
layer.on('mouseover', (e) => {
@@ -559,20 +552,6 @@ export function useAtlas() {
} catch (e ) {
console.error('Error fitting bounds', e)
}
// Mirror the map-click behaviour so an already-visited country can be removed
// straight from search. Tiny countries (Vatican City, Singapore) are hard to
// hit on the map, so search was the only way in — but it always opened the
// "Mark / Bucket" dialog with no Remove option.
const visited = data?.countries.find(c => c.code === country_code)
if (visited) {
if (visited.placeCount === 0 && visited.tripCount === 0) {
handleUnmarkCountry(country_code)
} else {
loadCountryDetailRef.current(country_code)
}
return
}
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
}
@@ -586,12 +565,10 @@ export function useAtlas() {
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
setData(prev => {
if (!prev || prev.countries.find(c => c.code === code)) return prev
const cont = continentForCountry(code)
return {
...prev,
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 },
}
})
} else {
@@ -602,12 +579,10 @@ export function useAtlas() {
if (!prev) return prev
const c = prev.countries.find(c => c.code === code)
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
const cont = continentForCountry(code)
return {
...prev,
countries: prev.countries.filter(c => c.code !== code),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
}
})
setVisitedRegions(prev => {
+1 -12
View File
@@ -33,7 +33,6 @@ export function useDashboard() {
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
const [loadError, setLoadError] = useState<boolean>(false)
const [stats, setStats] = useState<TravelStats | null>(null)
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
@@ -43,7 +42,7 @@ export function useDashboard() {
const [searchParams, setSearchParams] = useSearchParams()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode, authCheckFailed, loadUser } = useAuthStore()
const { demoMode } = useAuthStore()
const toggleViewMode = () => {
setViewMode(prev => {
@@ -75,22 +74,13 @@ export function useDashboard() {
const { trips, archivedTrips } = await tripRepo.list()
setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips))
setLoadError(false)
} catch {
setLoadError(true)
toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false)
}
}
// Re-run both the trip fetch and the auth check so a recovered backend clears
// the error banner (loadUser resets authCheckFailed on success). #1283
const retryLoad = () => {
loadUser({ silent: true })
loadTrips()
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
@@ -187,7 +177,6 @@ export function useDashboard() {
demoMode, locale, t, navigate,
// data + derived
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
loadError: loadError || authCheckFailed, retryLoad,
// ui state
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
+1 -2
View File
@@ -17,8 +17,7 @@ export function useSettings() {
const memoriesEnabled = addonEnabled('memories')
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
const llmEnabled = addonEnabled('llm_parsing')
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled
const [appVersion, setAppVersion] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState('display')
@@ -1,25 +0,0 @@
import { describe, it, expect } from 'vitest'
import { resolvePoolAssignmentId } from './tripPlannerModel'
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'
describe('resolvePoolAssignmentId', () => {
it('returns the lone assignment id when the place is assigned to exactly one day', () => {
const place = buildPlace({ id: 7 })
const assignment = buildAssignment({ id: 42, day_id: 3, place })
const assignments = { 3: [assignment], 4: [buildAssignment({ id: 99, day_id: 4 })] }
expect(resolvePoolAssignmentId(assignments, 7)).toBe(42)
})
it('returns null when the place is not assigned to any day', () => {
const assignments = { 3: [buildAssignment({ id: 99, day_id: 3 })] }
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
})
it('returns null when the place is assigned to multiple days (ambiguous time)', () => {
const assignments = {
3: [buildAssignment({ id: 1, day_id: 3, place: buildPlace({ id: 7 }) })],
4: [buildAssignment({ id: 2, day_id: 4, place: buildPlace({ id: 7 }) })],
}
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
})
})
@@ -1,24 +0,0 @@
/**
* Trip planner pure helpers React/IO-free logic shared by the data hook
* (useTripPlanner) and kept here so it can be unit-tested in isolation. Part of
* the FE "page = wiring container + data hook" convention (see PATTERN.md).
*/
import type { Assignment } from '../../types'
/**
* Resolve the day-assignment to use when a place is edited from the Places pool,
* where no day is in context. Times live per day-assignment (#1247), so we can
* only hydrate/persist a place's time when it is assigned to exactly one day.
* Returns that assignment's id, or null when the place has 0 or 2+ assignments
* (ambiguous the modal then hides the time fields).
*/
export function resolvePoolAssignmentId(
assignments: Record<string | number, Assignment[]>,
placeId: number,
): number | null {
const matches = Object.values(assignments)
.flat()
.filter((a) => a.place?.id === placeId)
return matches.length === 1 ? matches[0].id : null
}
+5 -147
View File
@@ -7,12 +7,9 @@ import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
import type { BookingImportPreviewItem } from '@trek/shared'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { offlineDb, getImportFiles, deleteImportFiles } from '../../db/offlineDb'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
import { offlineDb } from '../../db/offlineDb'
import { useAuthStore } from '../../store/authStore'
import { useResizablePanels } from '../../hooks/useResizablePanels'
import { useTripWebSocket } from '../../hooks/useTripWebSocket'
@@ -21,7 +18,6 @@ import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
import { resolvePoolAssignmentId } from './tripPlannerModel'
/**
* Trip planner page logic the big one. Owns the trip store wiring, addon
@@ -161,14 +157,6 @@ export function useTripPlanner() {
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
// Review-before-save import: each parsed item pre-fills the normal edit modal so
// the user checks/fixes it, then saves. A ref drives the queue (no stale closures).
const [reservationPrefill, setReservationPrefill] = useState<BookingReviewDraft | null>(null)
const [transportPrefill, setTransportPrefill] = useState<BookingReviewDraft | null>(null)
const [importReviewActive, setImportReviewActive] = useState(false)
const importQueueRef = useRef<BookingImportPreviewItem[]>([])
// The files this import was parsed from, so each reviewed booking can attach its source doc.
const importSourceFilesRef = useRef<File[]>([])
// Manual route planning: off by default, toggled from the day-plan footer. Mode
// (driving/walking) is per-session and selects which travel time the connectors show.
const [routeShown, setRouteShown] = useState(false)
@@ -300,7 +288,7 @@ export function useTripPlanner() {
})
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
const changed = dayId !== selectedDayId
@@ -435,16 +423,6 @@ export function useTripPlanner() {
}
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
// Open the place editor from any entry point (Places pool, inspector, map).
// Times live per day-assignment, so when no day is in context resolve the
// place's lone assignment to hydrate & persist its times; with 0 or 2+
// assignments the time is ambiguous and the modal hides the fields (#1247).
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
setEditingPlace(place)
setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id))
setShowPlaceForm(true)
}, [assignments])
const handleDeletePlace = useCallback((placeId) => {
setDeletePlaceId(placeId)
}, [])
@@ -589,20 +567,8 @@ export function useTripPlanner() {
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
try {
// Imported hotel with a reviewed address but no existing place picked: match
// an existing place by name, else geocode the address and create one, then link it.
const acc = (data as Record<string, any>).create_accommodation
if (data.type === 'hotel' && acc && acc.venue && !acc.place_id) {
acc.place_id = (await resolveImportedPlace(acc.venue)) ?? undefined
delete acc.venue
}
if (editingReservation) {
// Don't force a day here. The old code pinned it to the (often empty)
// selected day, which dropped the booking out of the Plan; preserving the
// old day_id instead left it stale when the date changed. Omitting it lets
// the server derive the day from the booking's date, or keep the current
// one when there is no date.
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false)
setEditingReservation(null)
@@ -614,9 +580,6 @@ export function useTripPlanner() {
const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationAdded'))
setShowReservationModal(false)
// An imported booking auto-creates a linked cost server-side; the saving client gets
// no budget:created echo, so refresh the budget items here to surface it without a reload.
if ((data as Record<string, unknown>).create_budget_entry) await tripActions.loadBudgetItems?.(tripId)
// Refresh accommodations if hotel was created
if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
@@ -641,8 +604,6 @@ export function useTripPlanner() {
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
// Surface the auto-created linked cost without a reload (no budget:created echo to us).
if (data.create_budget_entry) await tripActions.loadBudgetItems?.(tripId)
return r
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
@@ -658,108 +619,6 @@ export function useTripPlanner() {
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
// ── Review-before-save booking import ───────────────────────────────────────
// Match an existing trip place by name, else geocode the reviewed address and
// create one. Returns the place id (or null if even creation failed).
const resolveImportedPlace = async (venue: { name?: string; address?: string | null }): Promise<number | null> => {
const name = (venue.name || '').trim()
const n = name.toLowerCase()
if (n) {
const existing = places.find(p => p.name?.trim().toLowerCase() === n)
?? places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
if (existing) return existing.id
}
let lat: number | null = null
let lng: number | null = null
let address: string | null = venue.address ?? null
try {
const query = venue.address ? `${name} ${venue.address}`.trim() : name
if (query) {
const res = await mapsApi.search(query)
const hit = res?.places?.[0] as { lat?: number; lng?: number; address?: string } | undefined
if (hit && hit.lat != null && hit.lng != null) {
lat = hit.lat; lng = hit.lng
if (!address && hit.address) address = hit.address
}
}
} catch { /* geocode failure is non-fatal — create the place without coords */ }
try {
const place = await placesApi.create(tripId, { name: name || address || 'Accommodation', lat, lng, address } as never)
return (place as { id?: number })?.id ?? null
} catch { return null }
}
// Open the right edit modal for a parsed item, pre-filled, in create mode.
const openImportItem = (item: BookingImportPreviewItem) => {
const draft = parsedItemToDraft(item)
// Attach the file this item was parsed from so it lands in the booking's Files on save.
const srcName = item.source?.fileName
const srcFile = srcName ? importSourceFilesRef.current.find(f => f.name === srcName) : undefined
if (srcFile) draft._sourceFiles = [srcFile]
if (isTransportItem(item)) {
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
setEditingTransport(null); setTransportModalDayId(null)
setTransportPrefill(draft); setShowTransportModal(true)
} else {
setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null)
setEditingReservation(null)
setReservationPrefill(draft); setShowReservationModal(true)
}
}
const startImportReview = (items: BookingImportPreviewItem[], sourceFiles: File[] = []) => {
if (!items.length) return
importSourceFilesRef.current = sourceFiles
importQueueRef.current = items.slice(1)
setImportReviewActive(true)
openImportItem(items[0])
}
// Bridge: when a finished background import is sent here for review (the user hit
// "review" in the background widget, on this or any page), open the per-item flow.
// Lives in the hook so the page stays a pure wiring container.
const bgTasks = useBackgroundTasksStore((s) => s.tasks)
const dismissBgTask = useBackgroundTasksStore((s) => s.dismiss)
useEffect(() => {
const task = bgTasks.find(
(tk) => tk.tripId === String(tripId) && tk.status === 'done' && tk.reviewRequested && !tk.consumed,
)
if (task && task.items && task.items.length > 0) {
// Hand the items (and the source files, to attach to each booking) to the review flow
// and clear the widget entry — once the user hit "review", the background card is done.
const items = task.items
const jobId = task.id
const inMemory = task.sourceFiles
dismissBgTask(jobId)
// Prefer the in-memory files (immediate path); after a reload they live in IndexedDB.
void (async () => {
const files = inMemory && inMemory.length ? inMemory : await getImportFiles(jobId)
deleteImportFiles(jobId)
startImportReview(items, files)
})()
}
}, [bgTasks, tripId, startImportReview, dismissBgTask])
// Called when a reviewed item's modal closes (saved or skipped): open the next,
// or finish the review session and refresh accommodations.
const advanceImportReview = () => {
const queue = importQueueRef.current
if (queue.length > 0) {
importQueueRef.current = queue.slice(1)
openImportItem(queue[0])
return
}
importQueueRef.current = []
setImportReviewActive(false)
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null)
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
// Imported bookings auto-create their linked costs server-side, but the saving client
// suppresses its own budget:created echo (X-Socket-Id) — so reload the budget items here
// to surface those expenses without a manual page refresh.
tripActions.loadBudgetItems?.(tripId)
}
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
// Build placeId → order-number map from the selected day's assignments
@@ -818,7 +677,6 @@ export function useTripPlanner() {
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
@@ -827,7 +685,7 @@ export function useTripPlanner() {
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
+5 -23
View File
@@ -25,11 +25,6 @@ interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
/** The auth check (loadUser) failed for a non-401 reason while we were online
* the server was unreachable or erroring. Surfaced by the UI so a backend/IdP
* outage doesn't render as a blank, error-free page that looks like lost data.
* Transient, never persisted. #1283 */
authCheckFailed: boolean
error: string | null
demoMode: boolean
devMode: boolean
@@ -91,7 +86,6 @@ export const useAuthStore = create<AuthState>()(
user: null,
isAuthenticated: false,
isLoading: true,
authCheckFailed: false,
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
devMode: false,
@@ -206,7 +200,6 @@ export const useAuthStore = create<AuthState>()(
set({
user: null,
isAuthenticated: false,
authCheckFailed: false,
error: null,
})
},
@@ -222,33 +215,22 @@ export const useAuthStore = create<AuthState>()(
user: data.user,
isAuthenticated: true,
isLoading: false,
authCheckFailed: false,
})
await onAuthSuccess(data.user.id)
connect()
} catch (err: unknown) {
if (seq !== authSequence) return // stale response — ignore
const status = err && typeof err === 'object' && 'response' in err
? (err as { response?: { status?: number } }).response?.status
: undefined
if (status === 401) {
// Invalid/expired token — clear auth so the guard redirects to login.
// Only clear auth state on 401 (invalid/expired token), not on network errors
const isAuthError = err && typeof err === 'object' && 'response' in err &&
(err as { response?: { status?: number } }).response?.status === 401
if (isAuthError) {
set({
user: null,
isAuthenticated: false,
isLoading: false,
authCheckFailed: false,
})
} else if (status === undefined && typeof navigator !== 'undefined' && !navigator.onLine) {
// Genuinely offline — keep the persisted session so the PWA serves cached
// data without a scary error. This is the offline-first happy path.
set({ isLoading: false })
} else {
// Server erroring (5xx) or unreachable while we're online: keep the session
// (don't eject the user over a transient outage), but flag it so the UI can
// say "couldn't reach the server" instead of showing a blank, error-free
// page that looks like the user's trips were lost. #1283
set({ isLoading: false, authCheckFailed: true })
set({ isLoading: false })
}
}
},
-86
View File
@@ -1,86 +0,0 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { BookingImportPreviewItem } from '@trek/shared'
/**
* Tracks booking-import parses that run in the BACKGROUND (the async endpoint).
* The upload modal closes the moment a parse starts and adds a task here; the
* server pushes import:progress / import:done / import:error over the user's
* WebSocket (which reaches every page), and the global BackgroundTasksWidget
* renders the list. The trip page turns a finished task into the review flow.
*
* Persisted (minimal): the server keeps the job for ~10 min and exposes a status
* endpoint, so a reload mid-parse must NOT drop the widget we persist the running
* (and finished-but-unreviewed) tasks by id and the widget re-fetches their status
* on mount. We deliberately persist neither the parsed `items` (re-fetched) nor the
* transient review flags (so a reload never auto-reopens the review flow).
*/
export interface BackgroundImportTask {
id: string // server job id
tripId: string
label: string // file name(s) being parsed
status: 'running' | 'done' | 'error'
done: number
total: number
items?: BookingImportPreviewItem[]
warnings?: string[]
error?: string
reviewRequested?: boolean // user clicked "review" — the trip page consumes it
consumed?: boolean // review has been handed to the trip page
/** The uploaded files this parse ran on kept in memory so the review can attach the
* source document to each created booking. Not persisted (a File can't survive a reload). */
sourceFiles?: File[]
}
interface BackgroundTasksState {
tasks: BackgroundImportTask[]
addTask: (task: { id: string; tripId: string; label: string; total: number; files?: File[] }) => void
setProgress: (id: string, tripId: string, done: number, total: number) => void
setDone: (id: string, tripId: string, items: BookingImportPreviewItem[], warnings: string[]) => void
setError: (id: string, tripId: string, error: string) => void
requestReview: (id: string) => void
markConsumed: (id: string) => void
dismiss: (id: string) => void
}
export const useBackgroundTasksStore = create<BackgroundTasksState>()(
persist(
(set) => {
/** Update an existing task by id, or insert a fresh one (events can arrive before addTask). */
const upsert = (id: string, tripId: string, patch: Partial<BackgroundImportTask>) =>
set((state) => {
const idx = state.tasks.findIndex((t) => t.id === id)
if (idx === -1) {
const base: BackgroundImportTask = { id, tripId, label: 'Import', status: 'running', done: 0, total: 1 }
return { tasks: [...state.tasks, { ...base, ...patch }] }
}
const tasks = state.tasks.slice()
tasks[idx] = { ...tasks[idx], ...patch }
return { tasks }
})
return {
tasks: [],
addTask: ({ id, tripId, label, total, files }) => upsert(id, tripId, { label, total, status: 'running', done: 0, sourceFiles: files }),
setProgress: (id, tripId, done, total) => upsert(id, tripId, { done, total, status: 'running' }),
setDone: (id, tripId, items, warnings) => upsert(id, tripId, { status: 'done', items, warnings, done: items?.length ?? 0 }),
setError: (id, tripId, error) => upsert(id, tripId, { status: 'error', error }),
requestReview: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, reviewRequested: true } : t)) })),
markConsumed: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, consumed: true, reviewRequested: false } : t)) })),
dismiss: (id) => set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) })),
}
},
{
name: 'trek.bg-import-tasks',
// Persist only what survives a reload usefully: the job id/trip/label and a coarse
// status. The widget re-fetches each job's real status (and parsed items) on mount,
// so we keep neither the heavy `items`/`warnings` nor the transient review flags —
// that also guarantees a reload never re-opens the review flow on its own.
partialize: (state) => ({
tasks: state.tasks
.filter((t) => !t.consumed && t.status !== 'error')
.map((t) => ({ id: t.id, tripId: t.tripId, label: t.label, status: t.status, done: t.done, total: t.total })),
}),
},
),
)
-6
View File
@@ -30,7 +30,6 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
default_currency: 'USD',
language: localStorage.getItem('app_language') || 'en',
temperature_unit: 'fahrenheit',
distance_unit: 'metric',
time_format: '12h',
show_place_description: false,
optimize_from_accommodation: true,
@@ -38,13 +37,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
map_poi_pill_enabled: true,
mapbox_access_token: '',
mapbox_style: 'mapbox://styles/mapbox/standard',
maplibre_style: '',
mapbox_3d_enabled: true,
mapbox_quality_mode: false,
dashboard_fx_from: 'EUR',
dashboard_fx_to: 'USD',
// dashboard_timezones is intentionally left unset so the widget can tell "never
// chosen" (fall back to home + defaults) from an explicitly emptied list.
},
isLoaded: false,
+2 -29
View File
@@ -218,7 +218,7 @@
opacity: .88; margin-bottom: 16px; font-weight: 500;
}
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4); }
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; }
/* ----------------- boarding pass ----------------- */
.trek-dash .hero-pass {
@@ -422,7 +422,7 @@
.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); }
.trek-dash .trip-action-btn svg { width: 16px; height: 16px; }
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; }
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
.trek-dash .trip-body { padding: 18px 20px 20px; }
@@ -456,33 +456,6 @@
.trek-dash .add-trip-card .ttl { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
/* Error banner shown when the trip list or the auth check couldn't reach the
server, so a backend/IdP outage no longer looks like an empty (lost-data)
dashboard. Amber rather than red: it reassures (data is safe) more than it alarms. */
.trek-dash .dash-error {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 14px 18px; margin-bottom: 22px;
background: oklch(0.74 0.14 75 / 0.13);
border: 1px solid oklch(0.74 0.14 75 / 0.45);
border-radius: var(--r-md);
box-shadow: var(--sh-sm);
}
.trek-dash .dash-error-txt { flex: 1; min-width: 200px; font-size: 14px; color: var(--ink); }
.trek-dash .dash-error-retry {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 14px; border: none; border-radius: var(--r-xs);
background: var(--ink); color: var(--surface);
font-size: 13px; font-weight: 500; cursor: pointer;
transition: opacity .15s ease;
}
.trek-dash .dash-error-retry:hover { opacity: .88; }
/* Empty state a genuine "you have no trips yet" message, visually distinct
from the error banner above so an outage and a real empty list never look alike. */
.trek-dash .trips-empty { margin-bottom: 18px; }
.trek-dash .trips-empty h4 { font-size: 18px; font-weight: 600; color: var(--ink); margin: 0 0 6px; }
.trek-dash .trips-empty p { font-size: 14px; color: var(--ink-3); margin: 0; }
/* ----------------- tools sidebar ----------------- */
.trek-dash .tool {
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
+1 -16
View File
@@ -100,8 +100,6 @@ export interface TripFile {
url: string
}
export type DistanceUnit = 'metric' | 'imperial'
export interface Settings {
map_tile_url: string
default_lat: number
@@ -111,30 +109,17 @@ export interface Settings {
default_currency: string
language: string
temperature_unit: string
distance_unit?: DistanceUnit
time_format: string
show_place_description: boolean
blur_booking_codes?: boolean
map_booking_labels?: boolean
map_poi_pill_enabled?: boolean
optimize_from_accommodation?: boolean
map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl'
map_provider?: 'leaflet' | 'mapbox-gl'
mapbox_access_token?: string
mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
// Dashboard widget prefs — persisted server-side so a (docker) upgrade keeps them (#1311).
dashboard_fx_from?: string
dashboard_fx_to?: string
dashboard_timezones?: string[]
// AI booking-import fallback (per-user config; used when the admin has not set
// instance-wide config on the llm_parsing addon). llm_api_key is masked on read.
llm_provider?: 'local' | 'openai' | 'anthropic'
llm_model?: string
llm_base_url?: string
llm_multimodal?: boolean
llm_api_key?: string
}
export interface AssignmentsMap {
-36
View File
@@ -117,40 +117,4 @@ describe('getDayBookendHotels', () => {
const h = hotel({ place_lat: null, place_lng: null })
expect(getDayBookendHotels(days[1], days, [h])).toEqual({})
})
it('flags an arrival/check-in day as not slept-here in the morning (#1321)', () => {
// Day 1: you arrive from home and check in tonight, so the morning hotel is only a
// check-in fallback — no hotel → departure leg should be drawn.
const into = hotel({ start_day_id: 10, end_day_id: 30, place_lat: 3, place_lng: 4 })
const r = getDayBookendHotels(days[0], days, [into])
expect(r.morning).toBe(into)
expect(r.morningIsSleptHere).toBe(false)
expect(r.eveningIsOvernight).toBe(true)
// The optimizer anchor must stay a loop on the check-in day (values unchanged).
expect(getAccommodationAnchors(days[0], days, [into])).toEqual({ start: { lat: 3, lng: 4 }, end: { lat: 3, lng: 4 } })
})
it('flags a mid-stay day as slept-here and overnight', () => {
const h = hotel({ start_day_id: 10, end_day_id: 30 })
const r = getDayBookendHotels(days[1], days, [h])
expect(r.morningIsSleptHere).toBe(true)
expect(r.eveningIsOvernight).toBe(true)
})
it('an evening departure with no replacement check-in is not overnight (S7 mirror)', () => {
// You woke up here but check out today and board an evening transport — you do not
// sleep here tonight, so the last-stop → hotel leg must be droppable.
const h = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 })
const r = getDayBookendHotels(days[1], days, [h])
expect(r.morningIsSleptHere).toBe(true)
expect(r.eveningIsOvernight).toBe(false)
})
it('flags a transfer day as slept-here in the morning and overnight in the evening', () => {
const out = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 })
const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 })
const r = getDayBookendHotels(days[1], days, [out, into])
expect(r.morningIsSleptHere).toBe(true)
expect(r.eveningIsOvernight).toBe(true)
})
})
+1 -8
View File
@@ -12,7 +12,7 @@ export const getDayBookendHotels = (
day: Day,
days: Day[],
accommodations: Accommodation[],
): { morning?: Accommodation; evening?: Accommodation; morningIsSleptHere?: boolean; eveningIsOvernight?: boolean } => {
): { morning?: Accommodation; evening?: Accommodation } => {
const inRange = accommodations.filter(a =>
a.place_lat != null && a.place_lng != null &&
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
@@ -30,13 +30,6 @@ export const getDayBookendHotels = (
return {
morning: sleptHere ?? checkIn ?? inRange[0],
evening: checkIn ?? sleptHere ?? inRange[0],
// Provenance for the drawing consumers (map + sidebar). A hotel↔transport bookend
// is only real when you actually used the hotel: morningIsSleptHere is true only
// when you woke up there (not a check-in fallback on an arrival day), and
// eveningIsOvernight is true only when you sleep there tonight (you check in today,
// or an earlier stay continues past today). The optimizer keeps using the values.
morningIsSleptHere: sleptHere != null,
eveningIsOvernight: checkIn != null || (sleptHere != null && orderOf(sleptHere.end_day_id) > dayOrd),
}
}
+1 -25
View File
@@ -1,29 +1,5 @@
import { describe, it, expect } from 'vitest'
import { splitReservationDateTime, resolveDayId } from './formatters'
import type { Day } from '../types'
const days = [
{ id: 10, date: '2026-05-03' },
{ id: 11, date: '2026-05-04' },
{ id: 12, date: '2026-05-22' },
] as Day[]
describe('resolveDayId', () => {
it('returns the exact-match day id', () => {
expect(resolveDayId(days, '2026-05-04')).toBe(11)
})
it('accepts a full ISO timestamp', () => {
expect(resolveDayId(days, '2026-05-22T13:30:00')).toBe(12)
})
it('falls back to the nearest day when there is no exact match', () => {
expect(resolveDayId(days, '2026-05-05')).toBe(11)
})
it('returns "" for a missing/invalid date or no days', () => {
expect(resolveDayId(days, null)).toBe('')
expect(resolveDayId(days, 'not a date')).toBe('')
expect(resolveDayId([], '2026-05-04')).toBe('')
})
})
import { splitReservationDateTime } from './formatters'
describe('splitReservationDateTime', () => {
it('parses full ISO datetime', () => {
+1 -22
View File
@@ -1,4 +1,4 @@
import type { AssignmentsMap, Day } from '../types'
import type { AssignmentsMap } from '../types'
// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood,
// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country".
@@ -129,27 +129,6 @@ export function splitReservationDateTime(value?: string | null): { date: string
return { date: null, time: null }
}
/**
* Resolve a date (YYYY-MM-DD or an ISO timestamp) to a trip day id: exact match, else the
* nearest day so an out-of-range booking still lands on one. Returns '' when there is no
* usable date or the trip has no days callers read that as "no day selected".
*/
export function resolveDayId(days: Day[], value: string | null | undefined): Day['id'] | '' {
const date = value ? String(value).slice(0, 10) : ''
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || days.length === 0) return ''
const exact = days.find(d => d.date === date)
if (exact) return exact.id
const target = new Date(date).getTime()
let best: Day['id'] | '' = ''
let bestDiff = Infinity
for (const d of days) {
if (!d.date) continue
const diff = Math.abs(new Date(d.date).getTime() - target)
if (diff < bestDiff) { bestDiff = diff; best = d.id }
}
return best
}
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
const da = assignments[String(dayId)] || []
const total = da.reduce((s, a) => s + (parseFloat(String(a.place?.price ?? '')) || 0), 0)
-46
View File
@@ -1,46 +0,0 @@
import { describe, it, expect } from 'vitest'
import { convertDistance, formatDistance, getDistanceUnitLabel } from './units'
describe('units', () => {
describe('getDistanceUnitLabel', () => {
it('returns km for metric and mi for imperial', () => {
expect(getDistanceUnitLabel('metric')).toBe('km')
expect(getDistanceUnitLabel('imperial')).toBe('mi')
})
})
describe('convertDistance', () => {
it('keeps kilometres for metric', () => {
expect(convertDistance(10, 'metric')).toBe(10)
})
it('converts kilometres to miles for imperial', () => {
expect(convertDistance(10, 'imperial')).toBeCloseTo(6.21371, 4)
})
it('clamps negative and non-finite input to 0', () => {
expect(convertDistance(-5, 'imperial')).toBe(0)
expect(convertDistance(NaN, 'metric')).toBe(0)
expect(convertDistance(Infinity, 'metric')).toBe(0)
})
})
describe('formatDistance', () => {
it('shows metres below 1 km for metric', () => {
expect(formatDistance(0.3, 'metric')).toBe('300 m')
expect(formatDistance(0.05, 'metric')).toBe('50 m')
})
it('shows kilometres at or above 1 km for metric', () => {
expect(formatDistance(1.5, 'metric')).toBe('1.5 km')
expect(formatDistance(10, 'metric')).toBe('10 km')
})
it('shows miles for imperial', () => {
expect(formatDistance(10, 'imperial')).toBe('6.2 mi')
})
it('shows <0.1 for a tiny imperial distance', () => {
expect(formatDistance(0.05, 'imperial')).toBe('<0.1 mi')
})
it('clamps negative and non-finite input to 0', () => {
expect(formatDistance(-1, 'metric')).toBe('0 m')
expect(formatDistance(NaN, 'imperial')).toBe('0 mi')
})
})
})
-35
View File
@@ -1,35 +0,0 @@
import type { DistanceUnit } from '../types'
const KM_TO_MI = 0.621371
const M_TO_FT = 3.28084
export function getDistanceUnitLabel(unit: DistanceUnit): 'km' | 'mi' {
return unit === 'imperial' ? 'mi' : 'km'
}
/** Formats an elevation in metres as feet for imperial, so it doesn't mix with mi distances. */
export function formatElevation(meters: number, unit: DistanceUnit): string {
const safe = Number.isFinite(meters) ? meters : 0
return unit === 'imperial' ? `${Math.round(safe * M_TO_FT)} ft` : `${Math.round(safe)} m`
}
export function convertDistance(km: number, unit: DistanceUnit): number {
const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0
return unit === 'imperial' ? safeKm * KM_TO_MI : safeKm
}
export function formatDistance(km: number, unit: DistanceUnit): string {
const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0
// Metric keeps a metres reading below 1 km (e.g. "300 m"), matching the route
// connectors; imperial has no sub-mile unit, so short hops just show "0.x mi".
if (unit === 'metric' && safeKm < 1) {
return `${Math.round(safeKm * 1000)} m`
}
const value = convertDistance(safeKm, unit)
const label = getDistanceUnitLabel(unit)
const rounded = Math.round(value * 10) / 10
// String() keeps a '.' decimal regardless of locale, matching the rest of the app
// (toFixed elsewhere) and avoiding "1,5 km" in non-English environments.
const text = value > 0 && rounded === 0 ? '<0.1' : String(rounded)
return `${text} ${label}`
}

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